feat: add recurring places and transaction pagination
Build MoneyMap image / build-and-push (push) Failing after 4s
Build MoneyMap image / build-and-push (push) Failing after 4s
This commit is contained in:
@@ -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": []
|
||||
|
||||
+13
-13
@@ -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
|
||||
|
||||
@@ -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: |
|
||||
|
||||
+48
-48
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
+1911
-1911
File diff suppressed because it is too large
Load Diff
@@ -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<IMyService, MyService>();
|
||||
```
|
||||
|
||||
### Adding a Database Migration
|
||||
```bash
|
||||
dotnet ef migrations add MigrationName
|
||||
dotnet ef database update
|
||||
```
|
||||
|
||||
### Modifying Domain Models
|
||||
1. Update model class in `Models/`
|
||||
2. Update `MoneyMapContext.OnModelCreating()` if needed (relationships, indexes)
|
||||
3. Create and apply migration
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
1. **Service Layer Pattern**: Business logic lives in services, not pages
|
||||
2. **Result Pattern**: Services return result objects (not exceptions)
|
||||
3. **Dependency Injection**: All services injected via interfaces
|
||||
4. **Single Responsibility**: Each service has one clear purpose
|
||||
5. **Clean Architecture**: UI → Services → Data Access
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Duplicate Prevention**: Transactions have a unique constraint on (Date, Amount, Name, Memo, AccountId, CardId)
|
||||
- **Cascade Deletes**: Deleting a transaction cascades to receipts, parse logs, and line items
|
||||
- **Merchant Assignment**: Category mappings can auto-assign merchants to transactions
|
||||
- **Transfer Detection**: Transactions with `TransferToAccountId` are identified as transfers
|
||||
- **Receipt Parsing**: OpenAI API key required for receipt parsing (env var `OPENAI_API_KEY`)
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. Read [ARCHITECTURE.md](./ARCHITECTURE.md) for technical details
|
||||
2. Make changes to models, services, or pages
|
||||
3. Test locally
|
||||
4. Create database migration if schema changed
|
||||
5. **Update [ARCHITECTURE.md](./ARCHITECTURE.md) if architecture changes** (new models, services, workflows, etc.)
|
||||
6. Commit with descriptive message
|
||||
|
||||
## Important: Keep Documentation Updated
|
||||
|
||||
**When making architectural changes, always update [ARCHITECTURE.md](./ARCHITECTURE.md):**
|
||||
- Adding/removing domain models
|
||||
- Adding/removing services or changing their responsibilities
|
||||
- Modifying database schema or relationships
|
||||
- Adding new workflows or processes
|
||||
- Changing design patterns or conventions
|
||||
- Adding new pages or major features
|
||||
|
||||
This ensures both Claude Code and Codex CLI have accurate, up-to-date context.
|
||||
|
||||
## Configuration
|
||||
|
||||
See `appsettings.json`:
|
||||
- `ConnectionStrings:MoneyMapDb` - SQL Server connection
|
||||
- `OpenAI:ApiKey` - OpenAI API key (optional, use env var instead)
|
||||
- `Receipts:StoragePath` - Receipt storage location (relative to wwwroot)
|
||||
|
||||
## Testing
|
||||
|
||||
- All services use interfaces for mockability
|
||||
- Use in-memory database for integration tests
|
||||
- Mock `IReceiptParser` to avoid OpenAI API calls in tests
|
||||
|
||||
## Questions?
|
||||
|
||||
Refer to [ARCHITECTURE.md](./ARCHITECTURE.md) for comprehensive technical documentation including:
|
||||
- Detailed service descriptions
|
||||
- Database schema
|
||||
- Key workflows
|
||||
- Security considerations
|
||||
- Performance optimizations
|
||||
- Troubleshooting guide
|
||||
# MoneyMap - Claude Code Context
|
||||
|
||||
## Quick Overview
|
||||
|
||||
MoneyMap is an ASP.NET Core 8.0 Razor Pages application for personal finance tracking. Users can import bank transaction CSVs, categorize expenses, attach receipt images/PDFs, and parse receipts using OpenAI's Vision API.
|
||||
|
||||
## Architecture Documentation
|
||||
|
||||
**For detailed technical documentation, see [ARCHITECTURE.md](./ARCHITECTURE.md)**
|
||||
|
||||
The architecture document contains:
|
||||
- Complete technology stack
|
||||
- Core domain models (Transaction, Receipt, Card, Account, Merchant, etc.)
|
||||
- Service layer details (TransactionImporter, CardResolver, TransactionCategorizer, etc.)
|
||||
- Database schema and relationships
|
||||
- Key workflows and design patterns
|
||||
- Security and performance considerations
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
MoneyMap/
|
||||
├── Data/
|
||||
│ └── MoneyMapContext.cs # EF Core DbContext
|
||||
├── Models/
|
||||
│ ├── Account.cs # Bank accounts
|
||||
│ ├── Card.cs # Payment cards
|
||||
│ ├── Merchant.cs # Merchants/vendors
|
||||
│ ├── Transaction.cs # Core transaction entity
|
||||
│ ├── Receipt.cs # Receipt files
|
||||
│ ├── ReceiptLineItem.cs # Parsed line items
|
||||
│ └── ReceiptParseLog.cs # Parse attempt logs
|
||||
├── Services/
|
||||
│ ├── TransactionCategorizer.cs # Auto-categorization logic
|
||||
│ ├── ReceiptManager.cs # Receipt upload/storage
|
||||
│ └── OpenAIReceiptParser.cs # AI-powered receipt parsing
|
||||
├── Pages/
|
||||
│ ├── Index.cshtml[.cs] # Dashboard
|
||||
│ ├── Upload.cshtml[.cs] # CSV import
|
||||
│ ├── Transactions.cshtml[.cs] # Transaction list
|
||||
│ ├── EditTransaction.cshtml[.cs] # Edit transaction
|
||||
│ ├── ViewReceipt.cshtml[.cs] # Receipt details
|
||||
│ ├── CategoryMappings.cshtml[.cs]# Category rules
|
||||
│ ├── Merchants.cshtml[.cs] # Merchant management
|
||||
│ └── Recategorize.cshtml[.cs] # Bulk recategorization
|
||||
└── Program.cs # DI configuration
|
||||
```
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
### Adding a New Page
|
||||
1. Create `.cshtml` and `.cshtml.cs` files in `Pages/`
|
||||
2. Inherit from `PageModel`
|
||||
3. Add route via `@page` directive
|
||||
4. Register dependencies in constructor via DI
|
||||
|
||||
### Adding a New Service
|
||||
1. Create interface in appropriate namespace
|
||||
2. Create implementation class
|
||||
3. Register in `Program.cs`:
|
||||
```csharp
|
||||
builder.Services.AddScoped<IMyService, MyService>();
|
||||
```
|
||||
|
||||
### Adding a Database Migration
|
||||
```bash
|
||||
dotnet ef migrations add MigrationName
|
||||
dotnet ef database update
|
||||
```
|
||||
|
||||
### Modifying Domain Models
|
||||
1. Update model class in `Models/`
|
||||
2. Update `MoneyMapContext.OnModelCreating()` if needed (relationships, indexes)
|
||||
3. Create and apply migration
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
1. **Service Layer Pattern**: Business logic lives in services, not pages
|
||||
2. **Result Pattern**: Services return result objects (not exceptions)
|
||||
3. **Dependency Injection**: All services injected via interfaces
|
||||
4. **Single Responsibility**: Each service has one clear purpose
|
||||
5. **Clean Architecture**: UI → Services → Data Access
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Duplicate Prevention**: Transactions have a unique constraint on (Date, Amount, Name, Memo, AccountId, CardId)
|
||||
- **Cascade Deletes**: Deleting a transaction cascades to receipts, parse logs, and line items
|
||||
- **Merchant Assignment**: Category mappings can auto-assign merchants to transactions
|
||||
- **Transfer Detection**: Transactions with `TransferToAccountId` are identified as transfers
|
||||
- **Receipt Parsing**: OpenAI API key required for receipt parsing (env var `OPENAI_API_KEY`)
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. Read [ARCHITECTURE.md](./ARCHITECTURE.md) for technical details
|
||||
2. Make changes to models, services, or pages
|
||||
3. Test locally
|
||||
4. Create database migration if schema changed
|
||||
5. **Update [ARCHITECTURE.md](./ARCHITECTURE.md) if architecture changes** (new models, services, workflows, etc.)
|
||||
6. Commit with descriptive message
|
||||
|
||||
## Important: Keep Documentation Updated
|
||||
|
||||
**When making architectural changes, always update [ARCHITECTURE.md](./ARCHITECTURE.md):**
|
||||
- Adding/removing domain models
|
||||
- Adding/removing services or changing their responsibilities
|
||||
- Modifying database schema or relationships
|
||||
- Adding new workflows or processes
|
||||
- Changing design patterns or conventions
|
||||
- Adding new pages or major features
|
||||
|
||||
This ensures both Claude Code and Codex CLI have accurate, up-to-date context.
|
||||
|
||||
## Configuration
|
||||
|
||||
See `appsettings.json`:
|
||||
- `ConnectionStrings:MoneyMapDb` - SQL Server connection
|
||||
- `OpenAI:ApiKey` - OpenAI API key (optional, use env var instead)
|
||||
- `Receipts:StoragePath` - Receipt storage location (relative to wwwroot)
|
||||
|
||||
## Testing
|
||||
|
||||
- All services use interfaces for mockability
|
||||
- Use in-memory database for integration tests
|
||||
- Mock `IReceiptParser` to avoid OpenAI API calls in tests
|
||||
|
||||
## Questions?
|
||||
|
||||
Refer to [ARCHITECTURE.md](./ARCHITECTURE.md) for comprehensive technical documentation including:
|
||||
- Detailed service descriptions
|
||||
- Database schema
|
||||
- Key workflows
|
||||
- Security considerations
|
||||
- Performance optimizations
|
||||
- Troubleshooting guide
|
||||
|
||||
@@ -1,227 +1,276 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Models;
|
||||
|
||||
namespace MoneyMap.Data
|
||||
{
|
||||
public class MoneyMapContext : DbContext
|
||||
{
|
||||
public MoneyMapContext(DbContextOptions<MoneyMapContext> options) : base(options) { }
|
||||
|
||||
public DbSet<Card> Cards => Set<Card>();
|
||||
public DbSet<Account> Accounts => Set<Account>();
|
||||
public DbSet<Transaction> Transactions => Set<Transaction>();
|
||||
public DbSet<Transfer> Transfers => Set<Transfer>();
|
||||
public DbSet<Receipt> Receipts => Set<Receipt>();
|
||||
public DbSet<ReceiptParseLog> ReceiptParseLogs => Set<ReceiptParseLog>();
|
||||
public DbSet<ReceiptLineItem> ReceiptLineItems => Set<ReceiptLineItem>();
|
||||
|
||||
public DbSet<CategoryMapping> CategoryMappings => Set<CategoryMapping>();
|
||||
public DbSet<Merchant> Merchants => Set<Merchant>();
|
||||
public DbSet<Budget> Budgets => Set<Budget>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
// ---------- CARD ----------
|
||||
modelBuilder.Entity<Card>(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<Account>(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<Transaction>(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<Transfer>(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<Receipt>(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<ReceiptParseLog>(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<ReceiptLineItem>(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<Merchant>(e =>
|
||||
{
|
||||
e.Property(x => x.Name).HasMaxLength(100).IsRequired();
|
||||
e.HasIndex(x => x.Name).IsUnique();
|
||||
});
|
||||
|
||||
// ---------- CATEGORY MAPPING ----------
|
||||
modelBuilder.Entity<CategoryMapping>(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<Transaction>().HasIndex(x => x.Date);
|
||||
modelBuilder.Entity<Transaction>().HasIndex(x => x.Amount);
|
||||
modelBuilder.Entity<Transaction>().HasIndex(x => x.Category);
|
||||
modelBuilder.Entity<Transaction>().HasIndex(x => x.MerchantId);
|
||||
|
||||
// Composite indexes for common query patterns
|
||||
modelBuilder.Entity<Transaction>().HasIndex(x => new { x.AccountId, x.Category });
|
||||
modelBuilder.Entity<Transaction>().HasIndex(x => new { x.AccountId, x.Date });
|
||||
modelBuilder.Entity<Transaction>().HasIndex(x => new { x.MerchantId, x.Date });
|
||||
modelBuilder.Entity<Transaction>().HasIndex(x => new { x.CardId, x.Date });
|
||||
|
||||
// Receipt duplicate detection and lookup
|
||||
modelBuilder.Entity<Receipt>().HasIndex(x => x.FileHashSha256);
|
||||
modelBuilder.Entity<Receipt>().HasIndex(x => new { x.TransactionId, x.ReceiptDate });
|
||||
|
||||
// ---------- BUDGET ----------
|
||||
modelBuilder.Entity<Budget>(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<MoneyMapContext> options) : base(options) { }
|
||||
|
||||
public DbSet<Card> Cards => Set<Card>();
|
||||
public DbSet<Account> Accounts => Set<Account>();
|
||||
public DbSet<Transaction> Transactions => Set<Transaction>();
|
||||
public DbSet<Transfer> Transfers => Set<Transfer>();
|
||||
public DbSet<Receipt> Receipts => Set<Receipt>();
|
||||
public DbSet<ReceiptParseLog> ReceiptParseLogs => Set<ReceiptParseLog>();
|
||||
public DbSet<ReceiptLineItem> ReceiptLineItems => Set<ReceiptLineItem>();
|
||||
|
||||
public DbSet<CategoryMapping> CategoryMappings => Set<CategoryMapping>();
|
||||
public DbSet<Merchant> Merchants => Set<Merchant>();
|
||||
public DbSet<Budget> Budgets => Set<Budget>();
|
||||
public DbSet<RecurringPlace> RecurringPlaces => Set<RecurringPlace>();
|
||||
public DbSet<Category> Categories => Set<Category>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
// ---------- CARD ----------
|
||||
modelBuilder.Entity<Card>(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<Account>(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<Transaction>(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<Transfer>(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<Receipt>(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<ReceiptParseLog>(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<ReceiptLineItem>(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<Merchant>(e =>
|
||||
{
|
||||
e.Property(x => x.Name).HasMaxLength(100).IsRequired();
|
||||
e.HasIndex(x => x.Name).IsUnique();
|
||||
});
|
||||
|
||||
// ---------- CATEGORY MAPPING ----------
|
||||
modelBuilder.Entity<CategoryMapping>(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<Transaction>().HasIndex(x => x.Date);
|
||||
modelBuilder.Entity<Transaction>().HasIndex(x => x.Amount);
|
||||
modelBuilder.Entity<Transaction>().HasIndex(x => x.Category);
|
||||
modelBuilder.Entity<Transaction>().HasIndex(x => x.MerchantId);
|
||||
|
||||
// Composite indexes for common query patterns
|
||||
modelBuilder.Entity<Transaction>().HasIndex(x => new { x.AccountId, x.Category });
|
||||
modelBuilder.Entity<Transaction>().HasIndex(x => new { x.AccountId, x.Date });
|
||||
modelBuilder.Entity<Transaction>().HasIndex(x => new { x.MerchantId, x.Date });
|
||||
modelBuilder.Entity<Transaction>().HasIndex(x => new { x.CardId, x.Date });
|
||||
|
||||
// Receipt duplicate detection and lookup
|
||||
modelBuilder.Entity<Receipt>().HasIndex(x => x.FileHashSha256);
|
||||
modelBuilder.Entity<Receipt>().HasIndex(x => new { x.TransactionId, x.ReceiptDate });
|
||||
|
||||
// ---------- BUDGET ----------
|
||||
modelBuilder.Entity<Budget>(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<RecurringPlace>(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<Category>(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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Card> Cards { get; set; } = new List<Card>(); // Cards linked to this account
|
||||
public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>();
|
||||
public ICollection<Transfer> SourceTransfers { get; set; } = new List<Transfer>();
|
||||
public ICollection<Transfer> DestinationTransfers { get; set; } = new List<Transfer>();
|
||||
|
||||
[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<Card> Cards { get; set; } = new List<Card>(); // Cards linked to this account
|
||||
public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>();
|
||||
public ICollection<Transfer> SourceTransfers { get; set; } = new List<Transfer>();
|
||||
public ICollection<Transfer> DestinationTransfers { get; set; } = new List<Transfer>();
|
||||
|
||||
[NotMapped]
|
||||
public string DisplayLabel => string.IsNullOrEmpty(Nickname)
|
||||
? $"{Institution} {Last4} ({AccountType})"
|
||||
: $"{Nickname} ({Institution} {Last4})";
|
||||
}
|
||||
|
||||
@@ -1,137 +1,137 @@
|
||||
namespace MoneyMap.Models.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Complete financial audit response for AI analysis.
|
||||
/// </summary>
|
||||
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<BudgetStatusDto> Budgets { get; set; } = new();
|
||||
public List<CategorySpendingDto> SpendingByCategory { get; set; } = new();
|
||||
public List<MerchantSpendingDto> TopMerchants { get; set; } = new();
|
||||
public List<MonthlyTrendDto> MonthlyTrends { get; set; } = new();
|
||||
public List<AccountSummaryDto> Accounts { get; set; } = new();
|
||||
public List<AuditFlagDto> Flags { get; set; } = new();
|
||||
public List<TransactionDto>? Transactions { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// High-level financial statistics for the audit period.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Budget status with period information.
|
||||
/// </summary>
|
||||
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; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spending breakdown by category with optional budget correlation.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spending patterns by merchant.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Monthly income/expense/net trends.
|
||||
/// </summary>
|
||||
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<string, decimal> TopCategories { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-account transaction summary.
|
||||
/// </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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AI-friendly flag highlighting potential issues or observations.
|
||||
/// </summary>
|
||||
public class AuditFlagDto
|
||||
{
|
||||
public string Type { get; set; } = "";
|
||||
public string Severity { get; set; } = "";
|
||||
public string Message { get; set; } = "";
|
||||
public object? Details { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simplified transaction for export.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Complete financial audit response for AI analysis.
|
||||
/// </summary>
|
||||
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<BudgetStatusDto> Budgets { get; set; } = new();
|
||||
public List<CategorySpendingDto> SpendingByCategory { get; set; } = new();
|
||||
public List<MerchantSpendingDto> TopMerchants { get; set; } = new();
|
||||
public List<MonthlyTrendDto> MonthlyTrends { get; set; } = new();
|
||||
public List<AccountSummaryDto> Accounts { get; set; } = new();
|
||||
public List<AuditFlagDto> Flags { get; set; } = new();
|
||||
public List<TransactionDto>? Transactions { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// High-level financial statistics for the audit period.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Budget status with period information.
|
||||
/// </summary>
|
||||
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; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spending breakdown by category with optional budget correlation.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spending patterns by merchant.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Monthly income/expense/net trends.
|
||||
/// </summary>
|
||||
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<string, decimal> TopCategories { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-account transaction summary.
|
||||
/// </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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AI-friendly flag highlighting potential issues or observations.
|
||||
/// </summary>
|
||||
public class AuditFlagDto
|
||||
{
|
||||
public string Type { get; set; } = "";
|
||||
public string Severity { get; set; } = "";
|
||||
public string Message { get; set; } = "";
|
||||
public object? Details { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simplified transaction for export.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a spending budget for a category or total spending.
|
||||
/// When Category is null, this is a "Total" budget that tracks all spending.
|
||||
/// </summary>
|
||||
public class Budget
|
||||
{
|
||||
[Key]
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The category this budget applies to.
|
||||
/// Null means this is a total spending budget (all categories combined).
|
||||
/// </summary>
|
||||
[MaxLength(100)]
|
||||
public string? Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The budget limit amount (positive value).
|
||||
/// </summary>
|
||||
[Column(TypeName = "decimal(18,2)")]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The time period for this budget (Weekly, Monthly, Yearly).
|
||||
/// </summary>
|
||||
public BudgetPeriod Period { get; set; } = BudgetPeriod.Monthly;
|
||||
|
||||
/// <summary>
|
||||
/// When the budget becomes effective. Used to calculate period boundaries.
|
||||
/// </summary>
|
||||
public DateTime StartDate { get; set; } = DateTime.Today;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this budget is currently active.
|
||||
/// </summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional notes about this budget.
|
||||
/// </summary>
|
||||
[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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a spending budget for a category or total spending.
|
||||
/// When Category is null, this is a "Total" budget that tracks all spending.
|
||||
/// </summary>
|
||||
public class Budget
|
||||
{
|
||||
[Key]
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The category this budget applies to.
|
||||
/// Null means this is a total spending budget (all categories combined).
|
||||
/// </summary>
|
||||
[MaxLength(100)]
|
||||
public string? Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The budget limit amount (positive value).
|
||||
/// </summary>
|
||||
[Column(TypeName = "decimal(18,2)")]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The time period for this budget (Weekly, Monthly, Yearly).
|
||||
/// </summary>
|
||||
public BudgetPeriod Period { get; set; } = BudgetPeriod.Monthly;
|
||||
|
||||
/// <summary>
|
||||
/// When the budget becomes effective. Used to calculate period boundaries.
|
||||
/// </summary>
|
||||
public DateTime StartDate { get; set; } = DateTime.Today;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this budget is currently active.
|
||||
/// </summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional notes about this budget.
|
||||
/// </summary>
|
||||
[MaxLength(500)]
|
||||
public string? Notes { get; set; }
|
||||
|
||||
// Computed properties
|
||||
[NotMapped]
|
||||
public bool IsTotalBudget => Category == null;
|
||||
|
||||
[NotMapped]
|
||||
public string DisplayName => Category ?? "Total Spending";
|
||||
}
|
||||
|
||||
@@ -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<Transaction> Transactions { get; set; } = new List<Transaction>();
|
||||
|
||||
[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<Transaction> Transactions { get; set; } = new List<Transaction>();
|
||||
public ICollection<RecurringPlace> RecurringPlaces { get; set; } = new List<RecurringPlace>();
|
||||
|
||||
[NotMapped]
|
||||
public string DisplayLabel => string.IsNullOrEmpty(Nickname)
|
||||
? $"{Issuer} {Last4}"
|
||||
: $"{Nickname} ({Issuer} {Last4})";
|
||||
}
|
||||
|
||||
@@ -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<Category> Children { get; set; } = new List<Category>();
|
||||
|
||||
public ICollection<RecurringPlace> RecurringPlaces { get; set; } = new List<RecurringPlace>();
|
||||
public ICollection<CategoryMapping> CategoryMappings { get; set; } = new List<CategoryMapping>();
|
||||
public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>();
|
||||
|
||||
[NotMapped]
|
||||
public string DisplayLabel => Name;
|
||||
}
|
||||
@@ -1,47 +1,47 @@
|
||||
namespace MoneyMap.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a mapping rule that associates transaction name patterns with categories.
|
||||
/// Used for automatic categorization of transactions during import.
|
||||
/// </summary>
|
||||
public class CategoryMapping
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The category to assign when a transaction matches the pattern.
|
||||
/// </summary>
|
||||
public required string Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The pattern to match against transaction names (case-insensitive contains).
|
||||
/// </summary>
|
||||
public required string Pattern { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Higher priority mappings are checked first. Default is 0.
|
||||
/// </summary>
|
||||
public int Priority { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Optional merchant to auto-assign when this pattern matches.
|
||||
/// </summary>
|
||||
public int? MerchantId { get; set; }
|
||||
public Merchant? Merchant { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// AI confidence score when this rule was created by AI (0.0 - 1.0).
|
||||
/// </summary>
|
||||
public decimal? Confidence { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Who created this rule: "User" or "AI".
|
||||
/// </summary>
|
||||
public string? CreatedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When this rule was created.
|
||||
/// </summary>
|
||||
public DateTime? CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
namespace MoneyMap.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a mapping rule that associates transaction name patterns with categories.
|
||||
/// Used for automatic categorization of transactions during import.
|
||||
/// </summary>
|
||||
public class CategoryMapping
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The category to assign when a transaction matches the pattern.
|
||||
/// </summary>
|
||||
public required string Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The pattern to match against transaction names (case-insensitive contains).
|
||||
/// </summary>
|
||||
public required string Pattern { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Higher priority mappings are checked first. Default is 0.
|
||||
/// </summary>
|
||||
public int Priority { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Optional merchant to auto-assign when this pattern matches.
|
||||
/// </summary>
|
||||
public int? MerchantId { get; set; }
|
||||
public Merchant? Merchant { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// AI confidence score when this rule was created by AI (0.0 - 1.0).
|
||||
/// </summary>
|
||||
public decimal? Confidence { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Who created this rule: "User" or "AI".
|
||||
/// </summary>
|
||||
public string? CreatedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When this rule was created.
|
||||
/// </summary>
|
||||
public DateTime? CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +1,63 @@
|
||||
namespace MoneyMap.Models.Dashboard
|
||||
{
|
||||
/// <summary>
|
||||
/// Statistics displayed on the dashboard.
|
||||
/// </summary>
|
||||
public record DashboardStats(
|
||||
int TotalTransactions = 0,
|
||||
int Credits = 0,
|
||||
int Debits = 0,
|
||||
int Uncategorized = 0,
|
||||
int Receipts = 0,
|
||||
int Cards = 0);
|
||||
|
||||
/// <summary>
|
||||
/// Row representing spending in a category.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Row representing a recent transaction.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spending trends over time.
|
||||
/// </summary>
|
||||
public class SpendTrends
|
||||
{
|
||||
public List<string> Labels { get; set; } = new();
|
||||
public List<decimal> DebitsAbs { get; set; } = new();
|
||||
public List<decimal> Credits { get; set; } = new();
|
||||
public List<decimal> Net { get; set; } = new();
|
||||
public List<decimal> RunningBalance { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Complete dashboard data package.
|
||||
/// </summary>
|
||||
public class DashboardData
|
||||
{
|
||||
public required DashboardStats Stats { get; init; }
|
||||
public required List<TopCategoryRow> TopCategories { get; init; }
|
||||
public required List<RecentTransactionRow> RecentTransactions { get; init; }
|
||||
public required SpendTrends Trends { get; init; }
|
||||
}
|
||||
}
|
||||
namespace MoneyMap.Models.Dashboard
|
||||
{
|
||||
/// <summary>
|
||||
/// Statistics displayed on the dashboard.
|
||||
/// </summary>
|
||||
public record DashboardStats(
|
||||
int TotalTransactions = 0,
|
||||
int Credits = 0,
|
||||
int Debits = 0,
|
||||
int Uncategorized = 0,
|
||||
int Receipts = 0,
|
||||
int Cards = 0);
|
||||
|
||||
/// <summary>
|
||||
/// Row representing spending in a category.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Row representing a recent transaction.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spending trends over time.
|
||||
/// </summary>
|
||||
public class SpendTrends
|
||||
{
|
||||
public List<string> Labels { get; set; } = new();
|
||||
public List<decimal> DebitsAbs { get; set; } = new();
|
||||
public List<decimal> Credits { get; set; } = new();
|
||||
public List<decimal> Net { get; set; } = new();
|
||||
public List<decimal> RunningBalance { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Complete dashboard data package.
|
||||
/// </summary>
|
||||
public class DashboardData
|
||||
{
|
||||
public required DashboardStats Stats { get; init; }
|
||||
public required List<TopCategoryRow> TopCategories { get; init; }
|
||||
public required List<RecentTransactionRow> RecentTransactions { get; init; }
|
||||
public required SpendTrends Trends { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
namespace MoneyMap.Models.Import
|
||||
{
|
||||
/// <summary>
|
||||
/// Context for transaction import operations, containing payment selection mode and available options.
|
||||
/// </summary>
|
||||
public class ImportContext
|
||||
{
|
||||
public required PaymentSelectMode PaymentMode { get; init; }
|
||||
public int? SelectedCardId { get; init; }
|
||||
public int? SelectedAccountId { get; init; }
|
||||
public required List<Card> AvailableCards { get; init; }
|
||||
public required List<Account> AvailableAccounts { get; init; }
|
||||
public required string FileName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specifies how to determine the payment method for imported transactions.
|
||||
/// </summary>
|
||||
public enum PaymentSelectMode
|
||||
{
|
||||
/// <summary>Auto-detect from memo or filename.</summary>
|
||||
Auto,
|
||||
/// <summary>Use a specific card for all transactions.</summary>
|
||||
Card,
|
||||
/// <summary>Use a specific account for all transactions.</summary>
|
||||
Account
|
||||
}
|
||||
}
|
||||
namespace MoneyMap.Models.Import
|
||||
{
|
||||
/// <summary>
|
||||
/// Context for transaction import operations, containing payment selection mode and available options.
|
||||
/// </summary>
|
||||
public class ImportContext
|
||||
{
|
||||
public required PaymentSelectMode PaymentMode { get; init; }
|
||||
public int? SelectedCardId { get; init; }
|
||||
public int? SelectedAccountId { get; init; }
|
||||
public required List<Card> AvailableCards { get; init; }
|
||||
public required List<Account> AvailableAccounts { get; init; }
|
||||
public required string FileName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specifies how to determine the payment method for imported transactions.
|
||||
/// </summary>
|
||||
public enum PaymentSelectMode
|
||||
{
|
||||
/// <summary>Auto-detect from memo or filename.</summary>
|
||||
Auto,
|
||||
/// <summary>Use a specific card for all transactions.</summary>
|
||||
Card,
|
||||
/// <summary>Use a specific account for all transactions.</summary>
|
||||
Account
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +1,69 @@
|
||||
namespace MoneyMap.Models.Import
|
||||
{
|
||||
/// <summary>
|
||||
/// Result of an import operation, showing counts of processed transactions.
|
||||
/// </summary>
|
||||
public record ImportResult(int Total, int Inserted, int Skipped, string? Last4FromFile);
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper for import operation result with success/failure state.
|
||||
/// </summary>
|
||||
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 };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper for preview operation result with success/failure state.
|
||||
/// </summary>
|
||||
public class PreviewOperationResult
|
||||
{
|
||||
public bool IsSuccess { get; init; }
|
||||
public List<TransactionPreview>? Data { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
public static PreviewOperationResult Success(List<TransactionPreview> data) =>
|
||||
new() { IsSuccess = true, Data = data };
|
||||
|
||||
public static PreviewOperationResult Failure(string error) =>
|
||||
new() { IsSuccess = false, ErrorMessage = error };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Preview of a transaction before import, with duplicate detection info.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// User's selection for payment method during import confirmation.
|
||||
/// </summary>
|
||||
public class PaymentSelection
|
||||
{
|
||||
public int? AccountId { get; set; }
|
||||
public int? CardId { get; set; }
|
||||
public string? Category { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Key for detecting duplicate transactions.
|
||||
/// </summary>
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// Result of an import operation, showing counts of processed transactions.
|
||||
/// </summary>
|
||||
public record ImportResult(int Total, int Inserted, int Skipped, string? Last4FromFile);
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper for import operation result with success/failure state.
|
||||
/// </summary>
|
||||
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 };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper for preview operation result with success/failure state.
|
||||
/// </summary>
|
||||
public class PreviewOperationResult
|
||||
{
|
||||
public bool IsSuccess { get; init; }
|
||||
public List<TransactionPreview>? Data { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
public static PreviewOperationResult Success(List<TransactionPreview> data) =>
|
||||
new() { IsSuccess = true, Data = data };
|
||||
|
||||
public static PreviewOperationResult Failure(string error) =>
|
||||
new() { IsSuccess = false, ErrorMessage = error };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Preview of a transaction before import, with duplicate detection info.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// User's selection for payment method during import confirmation.
|
||||
/// </summary>
|
||||
public class PaymentSelection
|
||||
{
|
||||
public int? AccountId { get; set; }
|
||||
public int? CardId { get; set; }
|
||||
public string? Category { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Key for detecting duplicate transactions.
|
||||
/// </summary>
|
||||
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) { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
namespace MoneyMap.Models.Import
|
||||
{
|
||||
/// <summary>
|
||||
/// Result of resolving a payment method (card or account) for a transaction.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result when a card is used.
|
||||
/// </summary>
|
||||
public static PaymentResolutionResult SuccessCard(int cardId, int accountId, string last4) =>
|
||||
new() { IsSuccess = true, CardId = cardId, AccountId = accountId, Last4 = last4 };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result when a direct account transaction (no card).
|
||||
/// </summary>
|
||||
public static PaymentResolutionResult SuccessAccount(int accountId, string last4) =>
|
||||
new() { IsSuccess = true, AccountId = accountId, Last4 = last4 };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failure result with error message.
|
||||
/// </summary>
|
||||
public static PaymentResolutionResult Failure(string error) =>
|
||||
new() { IsSuccess = false, ErrorMessage = error };
|
||||
}
|
||||
}
|
||||
namespace MoneyMap.Models.Import
|
||||
{
|
||||
/// <summary>
|
||||
/// Result of resolving a payment method (card or account) for a transaction.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result when a card is used.
|
||||
/// </summary>
|
||||
public static PaymentResolutionResult SuccessCard(int cardId, int accountId, string last4) =>
|
||||
new() { IsSuccess = true, CardId = cardId, AccountId = accountId, Last4 = last4 };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result when a direct account transaction (no card).
|
||||
/// </summary>
|
||||
public static PaymentResolutionResult SuccessAccount(int accountId, string last4) =>
|
||||
new() { IsSuccess = true, AccountId = accountId, Last4 = last4 };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failure result with error message.
|
||||
/// </summary>
|
||||
public static PaymentResolutionResult Failure(string error) =>
|
||||
new() { IsSuccess = false, ErrorMessage = error };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; } = "";
|
||||
}
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
using CsvHelper.Configuration;
|
||||
|
||||
namespace MoneyMap.Models.Import;
|
||||
|
||||
public sealed class TransactionCsvRowMap : ClassMap<TransactionCsvRow>
|
||||
{
|
||||
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<TransactionCsvRow>
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Transaction> Transactions { get; set; } = new List<Transaction>();
|
||||
public ICollection<CategoryMapping> CategoryMappings { get; set; } = new List<CategoryMapping>();
|
||||
}
|
||||
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<Transaction> Transactions { get; set; } = new List<Transaction>();
|
||||
public ICollection<CategoryMapping> CategoryMappings { get; set; } = new List<CategoryMapping>();
|
||||
public ICollection<RecurringPlace> RecurringPlaces { get; set; } = new List<RecurringPlace>();
|
||||
}
|
||||
|
||||
@@ -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<ReceiptParseLog> ParseLogs { get; set; } = new List<ReceiptParseLog>();
|
||||
public ICollection<ReceiptLineItem> LineItems { get; set; } = new List<ReceiptLineItem>();
|
||||
}
|
||||
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<ReceiptParseLog> ParseLogs { get; set; } = new List<ReceiptParseLog>();
|
||||
public ICollection<ReceiptLineItem> LineItems { get; set; } = new List<ReceiptLineItem>();
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
/// <summary>
|
||||
/// Unit of Measure (ea, lb, gal, etc.)
|
||||
/// </summary>
|
||||
[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; }
|
||||
|
||||
/// <summary>
|
||||
/// Unit of Measure (ea, lb, gal, etc.)
|
||||
/// </summary>
|
||||
[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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
@@ -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<Receipt> Receipts { get; set; } = new List<Receipt>();
|
||||
|
||||
[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<Receipt> Receipts { get; set; } = new List<Receipt>();
|
||||
|
||||
[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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
||||
<PackageReference Include="Magick.NET-Q16-AnyCPU" Version="14.8.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" />
|
||||
<PackageReference Include="PdfPig" Version="0.1.11" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="Prompts\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
||||
<PackageReference Include="Magick.NET-Q16-AnyCPU" Version="14.8.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" />
|
||||
<PackageReference Include="PdfPig" Version="0.1.11" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="Prompts\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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
|
||||
@@ -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<MoneyMapContext>(options =>
|
||||
options.UseSqlServer(configuration.GetConnectionString("MoneyMapDb")));
|
||||
|
||||
services.AddMemoryCache();
|
||||
|
||||
// Core transaction and import services
|
||||
services.AddScoped<ITransactionImporter, TransactionImporter>();
|
||||
services.AddScoped<ICardResolver, CardResolver>();
|
||||
services.AddScoped<ITransactionCategorizer, TransactionCategorizer>();
|
||||
services.AddScoped<ITransactionService, TransactionService>();
|
||||
services.AddScoped<ITransactionStatisticsService, TransactionStatisticsService>();
|
||||
|
||||
// Entity management services
|
||||
services.AddScoped<IAccountService, AccountService>();
|
||||
services.AddScoped<ICardService, CardService>();
|
||||
services.AddScoped<IMerchantService, MerchantService>();
|
||||
services.AddScoped<IBudgetService, BudgetService>();
|
||||
|
||||
// Receipt services
|
||||
services.AddScoped<IReceiptMatchingService, ReceiptMatchingService>();
|
||||
services.AddScoped<IReceiptManager, ReceiptManager>();
|
||||
services.AddScoped<IReceiptAutoMapper, ReceiptAutoMapper>();
|
||||
services.AddScoped<IPdfToImageConverter, PdfToImageConverter>();
|
||||
|
||||
// Reference data and dashboard
|
||||
services.AddScoped<IReferenceDataService, ReferenceDataService>();
|
||||
services.AddScoped<IDashboardService, DashboardService>();
|
||||
services.AddScoped<IDashboardStatsCalculator, DashboardStatsCalculator>();
|
||||
services.AddScoped<ITopCategoriesProvider, TopCategoriesProvider>();
|
||||
services.AddScoped<IRecentTransactionsProvider, RecentTransactionsProvider>();
|
||||
services.AddScoped<ISpendTrendsProvider, SpendTrendsProvider>();
|
||||
|
||||
// AI services
|
||||
services.AddScoped<IAIToolExecutor, AIToolExecutor>();
|
||||
services.AddScoped<IFinancialAuditService, FinancialAuditService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Services;
|
||||
using MoneyMap.Services.AITools;
|
||||
|
||||
namespace MoneyMap.Core;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddMoneyMapCore(
|
||||
this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddDbContext<MoneyMapContext>(options =>
|
||||
options.UseSqlServer(configuration.GetConnectionString("MoneyMapDb")));
|
||||
|
||||
services.AddMemoryCache();
|
||||
|
||||
// Core transaction and import services
|
||||
services.AddScoped<ITransactionImporter, TransactionImporter>();
|
||||
services.AddScoped<ICardResolver, CardResolver>();
|
||||
services.AddScoped<ITransactionCategorizer, TransactionCategorizer>();
|
||||
services.AddScoped<ITransactionService, TransactionService>();
|
||||
services.AddScoped<ITransactionStatisticsService, TransactionStatisticsService>();
|
||||
|
||||
// Entity management services
|
||||
services.AddScoped<IAccountService, AccountService>();
|
||||
services.AddScoped<ICardService, CardService>();
|
||||
services.AddScoped<IMerchantService, MerchantService>();
|
||||
services.AddScoped<IBudgetService, BudgetService>();
|
||||
|
||||
// Receipt services
|
||||
services.AddScoped<IReceiptMatchingService, ReceiptMatchingService>();
|
||||
services.AddScoped<IReceiptManager, ReceiptManager>();
|
||||
services.AddScoped<IReceiptAutoMapper, ReceiptAutoMapper>();
|
||||
services.AddScoped<IPdfToImageConverter, PdfToImageConverter>();
|
||||
|
||||
// Reference data and dashboard
|
||||
services.AddScoped<IReferenceDataService, ReferenceDataService>();
|
||||
services.AddScoped<IDashboardService, DashboardService>();
|
||||
services.AddScoped<IDashboardStatsCalculator, DashboardStatsCalculator>();
|
||||
services.AddScoped<ITopCategoriesProvider, TopCategoriesProvider>();
|
||||
services.AddScoped<IRecentTransactionsProvider, RecentTransactionsProvider>();
|
||||
services.AddScoped<ISpendTrendsProvider, SpendTrendsProvider>();
|
||||
|
||||
// AI services
|
||||
services.AddScoped<IAIToolExecutor, AIToolExecutor>();
|
||||
services.AddScoped<IFinancialAuditService, FinancialAuditService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,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<ReceiptParseResult> 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<AIReceiptParser> _logger;
|
||||
private string? _promptTemplate;
|
||||
|
||||
public AIReceiptParser(
|
||||
MoneyMapContext db,
|
||||
IReceiptManager receiptManager,
|
||||
IPdfToImageConverter pdfConverter,
|
||||
IAIVisionClientResolver clientResolver,
|
||||
IMerchantService merchantService,
|
||||
IAIToolExecutor toolExecutor,
|
||||
IServiceProvider serviceProvider,
|
||||
IConfiguration configuration,
|
||||
ILogger<AIReceiptParser> logger)
|
||||
{
|
||||
_db = db;
|
||||
_receiptManager = receiptManager;
|
||||
_pdfConverter = pdfConverter;
|
||||
_clientResolver = clientResolver;
|
||||
_merchantService = merchantService;
|
||||
_toolExecutor = toolExecutor;
|
||||
_serviceProvider = serviceProvider;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ReceiptParseResult> 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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Call the vision client, using tool-use if the client supports it, or enriched prompt fallback for Ollama.
|
||||
/// </summary>
|
||||
private async Task<VisionApiResult> CallVisionClientAsync(
|
||||
IAIVisionClient client, string base64Data, string mediaType, string prompt, string model)
|
||||
{
|
||||
if (client is IAIToolAwareVisionClient toolAwareClient && toolAwareClient.SupportsToolUse)
|
||||
{
|
||||
_logger.LogInformation("Using tool-aware vision client for model {Model}", model);
|
||||
var tools = AIToolRegistry.GetAllTools();
|
||||
|
||||
return await toolAwareClient.AnalyzeImageWithToolsAsync(
|
||||
base64Data, mediaType, prompt, model,
|
||||
tools,
|
||||
toolCall => _toolExecutor.ExecuteAsync(toolCall),
|
||||
maxToolRounds: 5);
|
||||
}
|
||||
|
||||
// Fallback: standard call (Ollama gets enriched prompt via BuildPromptAsync)
|
||||
_logger.LogInformation("Using standard vision client for model {Model} (no tool use)", model);
|
||||
return await client.AnalyzeImageAsync(base64Data, mediaType, prompt, model);
|
||||
}
|
||||
|
||||
private async Task<(string Base64Data, string MediaType)> PrepareImageDataAsync(Receipt receipt, string filePath)
|
||||
{
|
||||
if (receipt.ContentType == "application/pdf")
|
||||
{
|
||||
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<string> 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<ParsedReceiptData>(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<IReceiptAutoMapper>();
|
||||
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<string> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the appropriate AI vision client based on model name.
|
||||
/// </summary>
|
||||
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<ParsedLineItem> 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 };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles AI responses that return suggestedTransactionId as a string ("null", "N/A", "123")
|
||||
/// instead of as a JSON number or null.
|
||||
/// </summary>
|
||||
public class NullableLongConverter : JsonConverter<long?>
|
||||
{
|
||||
public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
switch (reader.TokenType)
|
||||
{
|
||||
case JsonTokenType.Number:
|
||||
return reader.GetInt64();
|
||||
case JsonTokenType.String:
|
||||
var str = reader.GetString();
|
||||
if (string.IsNullOrWhiteSpace(str) ||
|
||||
str.Equals("null", StringComparison.OrdinalIgnoreCase) ||
|
||||
str.Equals("N/A", StringComparison.OrdinalIgnoreCase) ||
|
||||
str.Equals("none", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
return long.TryParse(str, out var val) ? val : null;
|
||||
case JsonTokenType.Null:
|
||||
return null;
|
||||
default:
|
||||
reader.Skip();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options)
|
||||
{
|
||||
if (value.HasValue)
|
||||
writer.WriteNumberValue(value.Value);
|
||||
else
|
||||
writer.WriteNullValue();
|
||||
}
|
||||
}
|
||||
}
|
||||
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<ReceiptParseResult> 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<AIReceiptParser> _logger;
|
||||
private string? _promptTemplate;
|
||||
|
||||
public AIReceiptParser(
|
||||
MoneyMapContext db,
|
||||
IReceiptManager receiptManager,
|
||||
IPdfToImageConverter pdfConverter,
|
||||
IAIVisionClientResolver clientResolver,
|
||||
IMerchantService merchantService,
|
||||
IAIToolExecutor toolExecutor,
|
||||
IServiceProvider serviceProvider,
|
||||
IConfiguration configuration,
|
||||
ILogger<AIReceiptParser> logger)
|
||||
{
|
||||
_db = db;
|
||||
_receiptManager = receiptManager;
|
||||
_pdfConverter = pdfConverter;
|
||||
_clientResolver = clientResolver;
|
||||
_merchantService = merchantService;
|
||||
_toolExecutor = toolExecutor;
|
||||
_serviceProvider = serviceProvider;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ReceiptParseResult> 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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Call the vision client, using tool-use if the client supports it, or enriched prompt fallback for Ollama.
|
||||
/// </summary>
|
||||
private async Task<VisionApiResult> CallVisionClientAsync(
|
||||
IAIVisionClient client, string base64Data, string mediaType, string prompt, string model)
|
||||
{
|
||||
if (client is IAIToolAwareVisionClient toolAwareClient && toolAwareClient.SupportsToolUse)
|
||||
{
|
||||
_logger.LogInformation("Using tool-aware vision client for model {Model}", model);
|
||||
var tools = AIToolRegistry.GetAllTools();
|
||||
|
||||
return await toolAwareClient.AnalyzeImageWithToolsAsync(
|
||||
base64Data, mediaType, prompt, model,
|
||||
tools,
|
||||
toolCall => _toolExecutor.ExecuteAsync(toolCall),
|
||||
maxToolRounds: 5);
|
||||
}
|
||||
|
||||
// Fallback: standard call (Ollama gets enriched prompt via BuildPromptAsync)
|
||||
_logger.LogInformation("Using standard vision client for model {Model} (no tool use)", model);
|
||||
return await client.AnalyzeImageAsync(base64Data, mediaType, prompt, model);
|
||||
}
|
||||
|
||||
private async Task<(string Base64Data, string MediaType)> PrepareImageDataAsync(Receipt receipt, string filePath)
|
||||
{
|
||||
if (receipt.ContentType == "application/pdf")
|
||||
{
|
||||
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<string> 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<ParsedReceiptData>(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<IReceiptAutoMapper>();
|
||||
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<string> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the appropriate AI vision client based on model name.
|
||||
/// </summary>
|
||||
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<ParsedLineItem> 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 };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles AI responses that return suggestedTransactionId as a string ("null", "N/A", "123")
|
||||
/// instead of as a JSON number or null.
|
||||
/// </summary>
|
||||
public class NullableLongConverter : JsonConverter<long?>
|
||||
{
|
||||
public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
switch (reader.TokenType)
|
||||
{
|
||||
case JsonTokenType.Number:
|
||||
return reader.GetInt64();
|
||||
case JsonTokenType.String:
|
||||
var str = reader.GetString();
|
||||
if (string.IsNullOrWhiteSpace(str) ||
|
||||
str.Equals("null", StringComparison.OrdinalIgnoreCase) ||
|
||||
str.Equals("N/A", StringComparison.OrdinalIgnoreCase) ||
|
||||
str.Equals("none", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
return long.TryParse(str, out var val) ? val : null;
|
||||
case JsonTokenType.Null:
|
||||
return null;
|
||||
default:
|
||||
reader.Skip();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options)
|
||||
{
|
||||
if (value.HasValue)
|
||||
writer.WriteNumberValue(value.Value);
|
||||
else
|
||||
writer.WriteNullValue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,159 +1,159 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MoneyMap.Services.AITools
|
||||
{
|
||||
/// <summary>
|
||||
/// Provider-agnostic tool definition for AI function calling.
|
||||
/// </summary>
|
||||
public class AIToolDefinition
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public List<AIToolParameter> Parameters { get; set; } = new();
|
||||
}
|
||||
|
||||
public class AIToolParameter
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Type { get; set; } = "string"; // string, number, integer
|
||||
public string Description { get; set; } = "";
|
||||
public bool Required { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a tool call from the AI model.
|
||||
/// </summary>
|
||||
public class AIToolCall
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public Dictionary<string, object?> Arguments { get; set; } = new();
|
||||
|
||||
public string? GetString(string key)
|
||||
{
|
||||
if (Arguments.TryGetValue(key, out var val) && val != null)
|
||||
return val.ToString();
|
||||
return null;
|
||||
}
|
||||
|
||||
public decimal? GetDecimal(string key)
|
||||
{
|
||||
if (Arguments.TryGetValue(key, out var val) && val != null)
|
||||
{
|
||||
if (decimal.TryParse(val.ToString(), out var d))
|
||||
return d;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public int? GetInt(string key)
|
||||
{
|
||||
if (Arguments.TryGetValue(key, out var val) && val != null)
|
||||
{
|
||||
if (int.TryParse(val.ToString(), out var i))
|
||||
return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of executing a tool, returned to the AI.
|
||||
/// </summary>
|
||||
public class AIToolResult
|
||||
{
|
||||
public string ToolCallId { get; set; } = "";
|
||||
public string Content { get; set; } = "";
|
||||
public bool IsError { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Static registry of all tools available to the receipt parsing AI.
|
||||
/// </summary>
|
||||
public static class AIToolRegistry
|
||||
{
|
||||
public static List<AIToolDefinition> GetAllTools() => new()
|
||||
{
|
||||
new AIToolDefinition
|
||||
{
|
||||
Name = "search_categories",
|
||||
Description = "Search existing expense categories in the system. Returns category names with their matching patterns and associated merchants. Use this to find the correct category name for line items and the overall receipt instead of inventing new ones.",
|
||||
Parameters = new()
|
||||
{
|
||||
new AIToolParameter
|
||||
{
|
||||
Name = "query",
|
||||
Type = "string",
|
||||
Description = "Optional filter text to search category names (e.g., 'grocery', 'utility'). Omit to get all categories.",
|
||||
Required = false
|
||||
}
|
||||
}
|
||||
},
|
||||
new AIToolDefinition
|
||||
{
|
||||
Name = "search_transactions",
|
||||
Description = "Search bank transactions to find one that matches this receipt. Returns transaction ID, date, amount, name, merchant, and category. Use this to suggest which transaction this receipt belongs to.",
|
||||
Parameters = new()
|
||||
{
|
||||
new AIToolParameter
|
||||
{
|
||||
Name = "merchant",
|
||||
Type = "string",
|
||||
Description = "Merchant or store name to search for (partial match)",
|
||||
Required = false
|
||||
},
|
||||
new AIToolParameter
|
||||
{
|
||||
Name = "minDate",
|
||||
Type = "string",
|
||||
Description = "Earliest transaction date (YYYY-MM-DD format)",
|
||||
Required = false
|
||||
},
|
||||
new AIToolParameter
|
||||
{
|
||||
Name = "maxDate",
|
||||
Type = "string",
|
||||
Description = "Latest transaction date (YYYY-MM-DD format)",
|
||||
Required = false
|
||||
},
|
||||
new AIToolParameter
|
||||
{
|
||||
Name = "minAmount",
|
||||
Type = "number",
|
||||
Description = "Minimum absolute transaction amount",
|
||||
Required = false
|
||||
},
|
||||
new AIToolParameter
|
||||
{
|
||||
Name = "maxAmount",
|
||||
Type = "number",
|
||||
Description = "Maximum absolute transaction amount",
|
||||
Required = false
|
||||
},
|
||||
new AIToolParameter
|
||||
{
|
||||
Name = "limit",
|
||||
Type = "integer",
|
||||
Description = "Maximum results to return (default 10, max 20)",
|
||||
Required = false
|
||||
}
|
||||
}
|
||||
},
|
||||
new AIToolDefinition
|
||||
{
|
||||
Name = "search_merchants",
|
||||
Description = "Search known merchants by name. Returns merchant name, transaction count, and most common category. Use this to find the correct merchant name and see what category is typically used for them.",
|
||||
Parameters = new()
|
||||
{
|
||||
new AIToolParameter
|
||||
{
|
||||
Name = "query",
|
||||
Type = "string",
|
||||
Description = "Merchant name to search for (partial match)",
|
||||
Required = true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MoneyMap.Services.AITools
|
||||
{
|
||||
/// <summary>
|
||||
/// Provider-agnostic tool definition for AI function calling.
|
||||
/// </summary>
|
||||
public class AIToolDefinition
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public List<AIToolParameter> Parameters { get; set; } = new();
|
||||
}
|
||||
|
||||
public class AIToolParameter
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Type { get; set; } = "string"; // string, number, integer
|
||||
public string Description { get; set; } = "";
|
||||
public bool Required { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a tool call from the AI model.
|
||||
/// </summary>
|
||||
public class AIToolCall
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public Dictionary<string, object?> Arguments { get; set; } = new();
|
||||
|
||||
public string? GetString(string key)
|
||||
{
|
||||
if (Arguments.TryGetValue(key, out var val) && val != null)
|
||||
return val.ToString();
|
||||
return null;
|
||||
}
|
||||
|
||||
public decimal? GetDecimal(string key)
|
||||
{
|
||||
if (Arguments.TryGetValue(key, out var val) && val != null)
|
||||
{
|
||||
if (decimal.TryParse(val.ToString(), out var d))
|
||||
return d;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public int? GetInt(string key)
|
||||
{
|
||||
if (Arguments.TryGetValue(key, out var val) && val != null)
|
||||
{
|
||||
if (int.TryParse(val.ToString(), out var i))
|
||||
return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of executing a tool, returned to the AI.
|
||||
/// </summary>
|
||||
public class AIToolResult
|
||||
{
|
||||
public string ToolCallId { get; set; } = "";
|
||||
public string Content { get; set; } = "";
|
||||
public bool IsError { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Static registry of all tools available to the receipt parsing AI.
|
||||
/// </summary>
|
||||
public static class AIToolRegistry
|
||||
{
|
||||
public static List<AIToolDefinition> GetAllTools() => new()
|
||||
{
|
||||
new AIToolDefinition
|
||||
{
|
||||
Name = "search_categories",
|
||||
Description = "Search existing expense categories in the system. Returns category names with their matching patterns and associated merchants. Use this to find the correct category name for line items and the overall receipt instead of inventing new ones.",
|
||||
Parameters = new()
|
||||
{
|
||||
new AIToolParameter
|
||||
{
|
||||
Name = "query",
|
||||
Type = "string",
|
||||
Description = "Optional filter text to search category names (e.g., 'grocery', 'utility'). Omit to get all categories.",
|
||||
Required = false
|
||||
}
|
||||
}
|
||||
},
|
||||
new AIToolDefinition
|
||||
{
|
||||
Name = "search_transactions",
|
||||
Description = "Search bank transactions to find one that matches this receipt. Returns transaction ID, date, amount, name, merchant, and category. Use this to suggest which transaction this receipt belongs to.",
|
||||
Parameters = new()
|
||||
{
|
||||
new AIToolParameter
|
||||
{
|
||||
Name = "merchant",
|
||||
Type = "string",
|
||||
Description = "Merchant or store name to search for (partial match)",
|
||||
Required = false
|
||||
},
|
||||
new AIToolParameter
|
||||
{
|
||||
Name = "minDate",
|
||||
Type = "string",
|
||||
Description = "Earliest transaction date (YYYY-MM-DD format)",
|
||||
Required = false
|
||||
},
|
||||
new AIToolParameter
|
||||
{
|
||||
Name = "maxDate",
|
||||
Type = "string",
|
||||
Description = "Latest transaction date (YYYY-MM-DD format)",
|
||||
Required = false
|
||||
},
|
||||
new AIToolParameter
|
||||
{
|
||||
Name = "minAmount",
|
||||
Type = "number",
|
||||
Description = "Minimum absolute transaction amount",
|
||||
Required = false
|
||||
},
|
||||
new AIToolParameter
|
||||
{
|
||||
Name = "maxAmount",
|
||||
Type = "number",
|
||||
Description = "Maximum absolute transaction amount",
|
||||
Required = false
|
||||
},
|
||||
new AIToolParameter
|
||||
{
|
||||
Name = "limit",
|
||||
Type = "integer",
|
||||
Description = "Maximum results to return (default 10, max 20)",
|
||||
Required = false
|
||||
}
|
||||
}
|
||||
},
|
||||
new AIToolDefinition
|
||||
{
|
||||
Name = "search_merchants",
|
||||
Description = "Search known merchants by name. Returns merchant name, transaction count, and most common category. Use this to find the correct merchant name and see what category is typically used for them.",
|
||||
Parameters = new()
|
||||
{
|
||||
new AIToolParameter
|
||||
{
|
||||
Name = "query",
|
||||
Type = "string",
|
||||
Description = "Merchant name to search for (partial match)",
|
||||
Required = true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,280 +1,280 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MoneyMap.Services.AITools
|
||||
{
|
||||
public interface IAIToolExecutor
|
||||
{
|
||||
/// <summary>
|
||||
/// Execute a single tool call and return the result as JSON.
|
||||
/// </summary>
|
||||
Task<AIToolResult> ExecuteAsync(AIToolCall toolCall);
|
||||
|
||||
/// <summary>
|
||||
/// Pre-fetch all relevant context as a text block for providers that don't support tool use (Ollama).
|
||||
/// </summary>
|
||||
Task<string> GetEnrichedContextAsync(DateTime? receiptDate = null, decimal? total = null, string? merchantHint = null);
|
||||
}
|
||||
|
||||
public class AIToolExecutor : IAIToolExecutor
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
private readonly ILogger<AIToolExecutor> _logger;
|
||||
private const int MaxResults = 20;
|
||||
|
||||
public AIToolExecutor(MoneyMapContext db, ILogger<AIToolExecutor> logger)
|
||||
{
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<AIToolResult> ExecuteAsync(AIToolCall toolCall)
|
||||
{
|
||||
_logger.LogInformation("Executing AI tool: {ToolName} with args: {Args}",
|
||||
toolCall.Name, JsonSerializer.Serialize(toolCall.Arguments));
|
||||
|
||||
try
|
||||
{
|
||||
var result = toolCall.Name switch
|
||||
{
|
||||
"search_categories" => await SearchCategoriesAsync(toolCall),
|
||||
"search_transactions" => await SearchTransactionsAsync(toolCall),
|
||||
"search_merchants" => await SearchMerchantsAsync(toolCall),
|
||||
_ => $"{{\"error\": \"Unknown tool: {toolCall.Name}\"}}"
|
||||
};
|
||||
|
||||
_logger.LogInformation("Tool {ToolName} returned {Length} chars", toolCall.Name, result.Length);
|
||||
|
||||
return new AIToolResult
|
||||
{
|
||||
ToolCallId = toolCall.Id,
|
||||
Content = result
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error executing tool {ToolName}", toolCall.Name);
|
||||
return new AIToolResult
|
||||
{
|
||||
ToolCallId = toolCall.Id,
|
||||
Content = JsonSerializer.Serialize(new { error = ex.Message }),
|
||||
IsError = true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> GetEnrichedContextAsync(DateTime? receiptDate, decimal? total, string? merchantHint)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("=== DATABASE CONTEXT (use this to match categories and transactions) ===");
|
||||
sb.AppendLine();
|
||||
|
||||
// Categories
|
||||
var categories = await _db.CategoryMappings
|
||||
.Include(cm => cm.Merchant)
|
||||
.OrderBy(cm => cm.Category)
|
||||
.ToListAsync();
|
||||
|
||||
var grouped = categories.GroupBy(c => c.Category).ToList();
|
||||
sb.AppendLine($"EXISTING CATEGORIES ({grouped.Count} total):");
|
||||
foreach (var group in grouped)
|
||||
{
|
||||
var patterns = group.Select(c => c.Pattern).Take(5);
|
||||
var merchants = group.Where(c => c.Merchant != null).Select(c => c.Merchant!.Name).Distinct().Take(3);
|
||||
sb.Append($" - {group.Key}: patterns=[{string.Join(", ", patterns)}]");
|
||||
if (merchants.Any())
|
||||
sb.Append($", merchants=[{string.Join(", ", merchants)}]");
|
||||
sb.AppendLine();
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
// Merchants matching hint
|
||||
if (!string.IsNullOrWhiteSpace(merchantHint))
|
||||
{
|
||||
var matchingMerchants = await _db.Merchants
|
||||
.Where(m => m.Name.Contains(merchantHint))
|
||||
.Select(m => new
|
||||
{
|
||||
m.Name,
|
||||
TransactionCount = m.Transactions.Count,
|
||||
TopCategory = m.Transactions
|
||||
.Where(t => t.Category != "")
|
||||
.GroupBy(t => t.Category)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Select(g => g.Key)
|
||||
.FirstOrDefault()
|
||||
})
|
||||
.Take(10)
|
||||
.ToListAsync();
|
||||
|
||||
if (matchingMerchants.Count > 0)
|
||||
{
|
||||
sb.AppendLine($"MATCHING MERCHANTS for \"{merchantHint}\":");
|
||||
foreach (var m in matchingMerchants)
|
||||
sb.AppendLine($" - {m.Name} ({m.TransactionCount} transactions, typical category: {m.TopCategory ?? "none"})");
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
// Matching transactions
|
||||
if (receiptDate.HasValue || total.HasValue)
|
||||
{
|
||||
var txQuery = _db.Transactions
|
||||
.Include(t => t.Merchant)
|
||||
.Where(t => !_db.Receipts.Any(r => r.TransactionId == t.Id))
|
||||
.AsQueryable();
|
||||
|
||||
if (receiptDate.HasValue)
|
||||
{
|
||||
var minDate = receiptDate.Value.AddDays(-1);
|
||||
var maxDate = receiptDate.Value.AddDays(7);
|
||||
txQuery = txQuery.Where(t => t.Date >= minDate && t.Date <= maxDate);
|
||||
}
|
||||
|
||||
if (total.HasValue)
|
||||
{
|
||||
var absTotal = Math.Abs(total.Value);
|
||||
var minAmt = absTotal * 0.9m;
|
||||
var maxAmt = absTotal * 1.1m;
|
||||
txQuery = txQuery.Where(t =>
|
||||
(t.Amount >= -maxAmt && t.Amount <= -minAmt) ||
|
||||
(t.Amount >= minAmt && t.Amount <= maxAmt));
|
||||
}
|
||||
|
||||
var transactions = await txQuery
|
||||
.OrderBy(t => t.Date)
|
||||
.Take(10)
|
||||
.ToListAsync();
|
||||
|
||||
if (transactions.Count > 0)
|
||||
{
|
||||
sb.AppendLine("CANDIDATE TRANSACTIONS (unmapped, matching date/amount):");
|
||||
foreach (var t in transactions)
|
||||
{
|
||||
sb.AppendLine($" - ID={t.Id}, Date={t.Date:yyyy-MM-dd}, Amount={t.Amount:C}, Name=\"{t.Name}\", " +
|
||||
$"Merchant={t.Merchant?.Name ?? "none"}, Category={t.Category}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine("=== END DATABASE CONTEXT ===");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private async Task<string> SearchCategoriesAsync(AIToolCall toolCall)
|
||||
{
|
||||
var query = toolCall.GetString("query");
|
||||
|
||||
var mappings = _db.CategoryMappings
|
||||
.Include(cm => cm.Merchant)
|
||||
.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
mappings = mappings.Where(cm => cm.Category.Contains(query));
|
||||
|
||||
var results = await mappings
|
||||
.OrderBy(cm => cm.Category)
|
||||
.ToListAsync();
|
||||
|
||||
var grouped = results
|
||||
.GroupBy(c => c.Category)
|
||||
.Take(MaxResults)
|
||||
.Select(g => new
|
||||
{
|
||||
category = g.Key,
|
||||
patterns = g.Select(c => c.Pattern).Take(5).ToList(),
|
||||
merchants = g.Where(c => c.Merchant != null)
|
||||
.Select(c => c.Merchant!.Name)
|
||||
.Distinct()
|
||||
.Take(5)
|
||||
.ToList()
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return JsonSerializer.Serialize(new { categories = grouped });
|
||||
}
|
||||
|
||||
private async Task<string> SearchTransactionsAsync(AIToolCall toolCall)
|
||||
{
|
||||
var merchant = toolCall.GetString("merchant");
|
||||
var minDateStr = toolCall.GetString("minDate");
|
||||
var maxDateStr = toolCall.GetString("maxDate");
|
||||
var minAmount = toolCall.GetDecimal("minAmount");
|
||||
var maxAmount = toolCall.GetDecimal("maxAmount");
|
||||
var limit = toolCall.GetInt("limit") ?? 10;
|
||||
limit = Math.Min(limit, MaxResults);
|
||||
|
||||
var txQuery = _db.Transactions
|
||||
.Include(t => t.Merchant)
|
||||
.Where(t => !_db.Receipts.Any(r => r.TransactionId == t.Id))
|
||||
.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(merchant))
|
||||
{
|
||||
txQuery = txQuery.Where(t =>
|
||||
t.Name.Contains(merchant) ||
|
||||
(t.Merchant != null && t.Merchant.Name.Contains(merchant)));
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(minDateStr, out var minDate))
|
||||
txQuery = txQuery.Where(t => t.Date >= minDate);
|
||||
|
||||
if (DateTime.TryParse(maxDateStr, out var maxDate))
|
||||
txQuery = txQuery.Where(t => t.Date <= maxDate);
|
||||
|
||||
if (minAmount.HasValue)
|
||||
{
|
||||
var min = minAmount.Value;
|
||||
txQuery = txQuery.Where(t => t.Amount <= -min || t.Amount >= min);
|
||||
}
|
||||
|
||||
if (maxAmount.HasValue)
|
||||
{
|
||||
var max = maxAmount.Value;
|
||||
txQuery = txQuery.Where(t => t.Amount >= -max && t.Amount <= max);
|
||||
}
|
||||
|
||||
var transactions = await txQuery
|
||||
.OrderByDescending(t => t.Date)
|
||||
.Take(limit)
|
||||
.Select(t => new
|
||||
{
|
||||
id = t.Id,
|
||||
date = t.Date.ToString("yyyy-MM-dd"),
|
||||
amount = t.Amount,
|
||||
name = t.Name,
|
||||
merchant = t.Merchant != null ? t.Merchant.Name : null,
|
||||
category = t.Category
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return JsonSerializer.Serialize(new { transactions });
|
||||
}
|
||||
|
||||
private async Task<string> SearchMerchantsAsync(AIToolCall toolCall)
|
||||
{
|
||||
var query = toolCall.GetString("query") ?? "";
|
||||
|
||||
var merchants = await _db.Merchants
|
||||
.Where(m => m.Name.Contains(query))
|
||||
.Select(m => new
|
||||
{
|
||||
name = m.Name,
|
||||
transactionCount = m.Transactions.Count,
|
||||
topCategory = m.Transactions
|
||||
.Where(t => t.Category != "")
|
||||
.GroupBy(t => t.Category)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Select(g => g.Key)
|
||||
.FirstOrDefault()
|
||||
})
|
||||
.Take(MaxResults)
|
||||
.ToListAsync();
|
||||
|
||||
return JsonSerializer.Serialize(new { merchants });
|
||||
}
|
||||
}
|
||||
}
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MoneyMap.Services.AITools
|
||||
{
|
||||
public interface IAIToolExecutor
|
||||
{
|
||||
/// <summary>
|
||||
/// Execute a single tool call and return the result as JSON.
|
||||
/// </summary>
|
||||
Task<AIToolResult> ExecuteAsync(AIToolCall toolCall);
|
||||
|
||||
/// <summary>
|
||||
/// Pre-fetch all relevant context as a text block for providers that don't support tool use (Ollama).
|
||||
/// </summary>
|
||||
Task<string> GetEnrichedContextAsync(DateTime? receiptDate = null, decimal? total = null, string? merchantHint = null);
|
||||
}
|
||||
|
||||
public class AIToolExecutor : IAIToolExecutor
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
private readonly ILogger<AIToolExecutor> _logger;
|
||||
private const int MaxResults = 20;
|
||||
|
||||
public AIToolExecutor(MoneyMapContext db, ILogger<AIToolExecutor> logger)
|
||||
{
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<AIToolResult> ExecuteAsync(AIToolCall toolCall)
|
||||
{
|
||||
_logger.LogInformation("Executing AI tool: {ToolName} with args: {Args}",
|
||||
toolCall.Name, JsonSerializer.Serialize(toolCall.Arguments));
|
||||
|
||||
try
|
||||
{
|
||||
var result = toolCall.Name switch
|
||||
{
|
||||
"search_categories" => await SearchCategoriesAsync(toolCall),
|
||||
"search_transactions" => await SearchTransactionsAsync(toolCall),
|
||||
"search_merchants" => await SearchMerchantsAsync(toolCall),
|
||||
_ => $"{{\"error\": \"Unknown tool: {toolCall.Name}\"}}"
|
||||
};
|
||||
|
||||
_logger.LogInformation("Tool {ToolName} returned {Length} chars", toolCall.Name, result.Length);
|
||||
|
||||
return new AIToolResult
|
||||
{
|
||||
ToolCallId = toolCall.Id,
|
||||
Content = result
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error executing tool {ToolName}", toolCall.Name);
|
||||
return new AIToolResult
|
||||
{
|
||||
ToolCallId = toolCall.Id,
|
||||
Content = JsonSerializer.Serialize(new { error = ex.Message }),
|
||||
IsError = true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> GetEnrichedContextAsync(DateTime? receiptDate, decimal? total, string? merchantHint)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("=== DATABASE CONTEXT (use this to match categories and transactions) ===");
|
||||
sb.AppendLine();
|
||||
|
||||
// Categories
|
||||
var categories = await _db.CategoryMappings
|
||||
.Include(cm => cm.Merchant)
|
||||
.OrderBy(cm => cm.Category)
|
||||
.ToListAsync();
|
||||
|
||||
var grouped = categories.GroupBy(c => c.Category).ToList();
|
||||
sb.AppendLine($"EXISTING CATEGORIES ({grouped.Count} total):");
|
||||
foreach (var group in grouped)
|
||||
{
|
||||
var patterns = group.Select(c => c.Pattern).Take(5);
|
||||
var merchants = group.Where(c => c.Merchant != null).Select(c => c.Merchant!.Name).Distinct().Take(3);
|
||||
sb.Append($" - {group.Key}: patterns=[{string.Join(", ", patterns)}]");
|
||||
if (merchants.Any())
|
||||
sb.Append($", merchants=[{string.Join(", ", merchants)}]");
|
||||
sb.AppendLine();
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
// Merchants matching hint
|
||||
if (!string.IsNullOrWhiteSpace(merchantHint))
|
||||
{
|
||||
var matchingMerchants = await _db.Merchants
|
||||
.Where(m => m.Name.Contains(merchantHint))
|
||||
.Select(m => new
|
||||
{
|
||||
m.Name,
|
||||
TransactionCount = m.Transactions.Count,
|
||||
TopCategory = m.Transactions
|
||||
.Where(t => t.Category != "")
|
||||
.GroupBy(t => t.Category)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Select(g => g.Key)
|
||||
.FirstOrDefault()
|
||||
})
|
||||
.Take(10)
|
||||
.ToListAsync();
|
||||
|
||||
if (matchingMerchants.Count > 0)
|
||||
{
|
||||
sb.AppendLine($"MATCHING MERCHANTS for \"{merchantHint}\":");
|
||||
foreach (var m in matchingMerchants)
|
||||
sb.AppendLine($" - {m.Name} ({m.TransactionCount} transactions, typical category: {m.TopCategory ?? "none"})");
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
// Matching transactions
|
||||
if (receiptDate.HasValue || total.HasValue)
|
||||
{
|
||||
var txQuery = _db.Transactions
|
||||
.Include(t => t.Merchant)
|
||||
.Where(t => !_db.Receipts.Any(r => r.TransactionId == t.Id))
|
||||
.AsQueryable();
|
||||
|
||||
if (receiptDate.HasValue)
|
||||
{
|
||||
var minDate = receiptDate.Value.AddDays(-1);
|
||||
var maxDate = receiptDate.Value.AddDays(7);
|
||||
txQuery = txQuery.Where(t => t.Date >= minDate && t.Date <= maxDate);
|
||||
}
|
||||
|
||||
if (total.HasValue)
|
||||
{
|
||||
var absTotal = Math.Abs(total.Value);
|
||||
var minAmt = absTotal * 0.9m;
|
||||
var maxAmt = absTotal * 1.1m;
|
||||
txQuery = txQuery.Where(t =>
|
||||
(t.Amount >= -maxAmt && t.Amount <= -minAmt) ||
|
||||
(t.Amount >= minAmt && t.Amount <= maxAmt));
|
||||
}
|
||||
|
||||
var transactions = await txQuery
|
||||
.OrderBy(t => t.Date)
|
||||
.Take(10)
|
||||
.ToListAsync();
|
||||
|
||||
if (transactions.Count > 0)
|
||||
{
|
||||
sb.AppendLine("CANDIDATE TRANSACTIONS (unmapped, matching date/amount):");
|
||||
foreach (var t in transactions)
|
||||
{
|
||||
sb.AppendLine($" - ID={t.Id}, Date={t.Date:yyyy-MM-dd}, Amount={t.Amount:C}, Name=\"{t.Name}\", " +
|
||||
$"Merchant={t.Merchant?.Name ?? "none"}, Category={t.Category}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine("=== END DATABASE CONTEXT ===");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private async Task<string> SearchCategoriesAsync(AIToolCall toolCall)
|
||||
{
|
||||
var query = toolCall.GetString("query");
|
||||
|
||||
var mappings = _db.CategoryMappings
|
||||
.Include(cm => cm.Merchant)
|
||||
.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
mappings = mappings.Where(cm => cm.Category.Contains(query));
|
||||
|
||||
var results = await mappings
|
||||
.OrderBy(cm => cm.Category)
|
||||
.ToListAsync();
|
||||
|
||||
var grouped = results
|
||||
.GroupBy(c => c.Category)
|
||||
.Take(MaxResults)
|
||||
.Select(g => new
|
||||
{
|
||||
category = g.Key,
|
||||
patterns = g.Select(c => c.Pattern).Take(5).ToList(),
|
||||
merchants = g.Where(c => c.Merchant != null)
|
||||
.Select(c => c.Merchant!.Name)
|
||||
.Distinct()
|
||||
.Take(5)
|
||||
.ToList()
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return JsonSerializer.Serialize(new { categories = grouped });
|
||||
}
|
||||
|
||||
private async Task<string> SearchTransactionsAsync(AIToolCall toolCall)
|
||||
{
|
||||
var merchant = toolCall.GetString("merchant");
|
||||
var minDateStr = toolCall.GetString("minDate");
|
||||
var maxDateStr = toolCall.GetString("maxDate");
|
||||
var minAmount = toolCall.GetDecimal("minAmount");
|
||||
var maxAmount = toolCall.GetDecimal("maxAmount");
|
||||
var limit = toolCall.GetInt("limit") ?? 10;
|
||||
limit = Math.Min(limit, MaxResults);
|
||||
|
||||
var txQuery = _db.Transactions
|
||||
.Include(t => t.Merchant)
|
||||
.Where(t => !_db.Receipts.Any(r => r.TransactionId == t.Id))
|
||||
.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(merchant))
|
||||
{
|
||||
txQuery = txQuery.Where(t =>
|
||||
t.Name.Contains(merchant) ||
|
||||
(t.Merchant != null && t.Merchant.Name.Contains(merchant)));
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(minDateStr, out var minDate))
|
||||
txQuery = txQuery.Where(t => t.Date >= minDate);
|
||||
|
||||
if (DateTime.TryParse(maxDateStr, out var maxDate))
|
||||
txQuery = txQuery.Where(t => t.Date <= maxDate);
|
||||
|
||||
if (minAmount.HasValue)
|
||||
{
|
||||
var min = minAmount.Value;
|
||||
txQuery = txQuery.Where(t => t.Amount <= -min || t.Amount >= min);
|
||||
}
|
||||
|
||||
if (maxAmount.HasValue)
|
||||
{
|
||||
var max = maxAmount.Value;
|
||||
txQuery = txQuery.Where(t => t.Amount >= -max && t.Amount <= max);
|
||||
}
|
||||
|
||||
var transactions = await txQuery
|
||||
.OrderByDescending(t => t.Date)
|
||||
.Take(limit)
|
||||
.Select(t => new
|
||||
{
|
||||
id = t.Id,
|
||||
date = t.Date.ToString("yyyy-MM-dd"),
|
||||
amount = t.Amount,
|
||||
name = t.Name,
|
||||
merchant = t.Merchant != null ? t.Merchant.Name : null,
|
||||
category = t.Category
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return JsonSerializer.Serialize(new { transactions });
|
||||
}
|
||||
|
||||
private async Task<string> SearchMerchantsAsync(AIToolCall toolCall)
|
||||
{
|
||||
var query = toolCall.GetString("query") ?? "";
|
||||
|
||||
var merchants = await _db.Merchants
|
||||
.Where(m => m.Name.Contains(query))
|
||||
.Select(m => new
|
||||
{
|
||||
name = m.Name,
|
||||
transactionCount = m.Transactions.Count,
|
||||
topCategory = m.Transactions
|
||||
.Where(t => t.Category != "")
|
||||
.GroupBy(t => t.Category)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Select(g => g.Key)
|
||||
.FirstOrDefault()
|
||||
})
|
||||
.Take(MaxResults)
|
||||
.ToListAsync();
|
||||
|
||||
return JsonSerializer.Serialize(new { merchants });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1024
-1024
File diff suppressed because it is too large
Load Diff
@@ -1,202 +1,202 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
|
||||
namespace MoneyMap.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for account management including retrieval, validation, and deletion.
|
||||
/// </summary>
|
||||
public interface IAccountService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets an account by ID with optional related data.
|
||||
/// </summary>
|
||||
Task<Account?> GetAccountByIdAsync(int id, bool includeRelated = false);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all accounts with optional statistics.
|
||||
/// </summary>
|
||||
Task<List<AccountWithStats>> GetAllAccountsWithStatsAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Gets account details with cards and transaction count.
|
||||
/// </summary>
|
||||
Task<AccountDetails?> GetAccountDetailsAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an account can be deleted (no transactions exist).
|
||||
/// </summary>
|
||||
Task<DeleteValidationResult> CanDeleteAccountAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an account if it has no associated transactions.
|
||||
/// </summary>
|
||||
Task<DeleteResult> DeleteAccountAsync(int id);
|
||||
}
|
||||
|
||||
public class AccountService : IAccountService
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public AccountService(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<Account?> 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<List<AccountWithStats>> 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<AccountDetails?> 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<DeleteValidationResult> 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<DeleteResult> 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<CardWithStats> 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;
|
||||
|
||||
/// <summary>
|
||||
/// Service for account management including retrieval, validation, and deletion.
|
||||
/// </summary>
|
||||
public interface IAccountService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets an account by ID with optional related data.
|
||||
/// </summary>
|
||||
Task<Account?> GetAccountByIdAsync(int id, bool includeRelated = false);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all accounts with optional statistics.
|
||||
/// </summary>
|
||||
Task<List<AccountWithStats>> GetAllAccountsWithStatsAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Gets account details with cards and transaction count.
|
||||
/// </summary>
|
||||
Task<AccountDetails?> GetAccountDetailsAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an account can be deleted (no transactions exist).
|
||||
/// </summary>
|
||||
Task<DeleteValidationResult> CanDeleteAccountAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an account if it has no associated transactions.
|
||||
/// </summary>
|
||||
Task<DeleteResult> DeleteAccountAsync(int id);
|
||||
}
|
||||
|
||||
public class AccountService : IAccountService
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public AccountService(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<Account?> 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<List<AccountWithStats>> 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<AccountDetails?> 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<DeleteValidationResult> 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<DeleteResult> 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<CardWithStats> 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; } = "";
|
||||
}
|
||||
|
||||
@@ -1,347 +1,347 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
|
||||
namespace MoneyMap.Services;
|
||||
|
||||
public interface IBudgetService
|
||||
{
|
||||
// CRUD operations
|
||||
Task<List<Budget>> GetAllBudgetsAsync(bool activeOnly = true);
|
||||
Task<Budget?> GetBudgetByIdAsync(int id);
|
||||
Task<BudgetOperationResult> CreateBudgetAsync(Budget budget);
|
||||
Task<BudgetOperationResult> UpdateBudgetAsync(Budget budget);
|
||||
Task<BudgetOperationResult> DeleteBudgetAsync(int id);
|
||||
|
||||
// Budget status calculations
|
||||
Task<List<BudgetStatus>> GetAllBudgetStatusesAsync(DateTime? asOfDate = null);
|
||||
Task<BudgetStatus?> GetBudgetStatusAsync(int budgetId, DateTime? asOfDate = null);
|
||||
|
||||
// Helper methods
|
||||
Task<List<string>> 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<List<Budget>> 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<Budget?> GetBudgetByIdAsync(int id)
|
||||
{
|
||||
return await _db.Budgets.FindAsync(id);
|
||||
}
|
||||
|
||||
public async Task<BudgetOperationResult> 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<BudgetOperationResult> 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<BudgetOperationResult> 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<List<BudgetStatus>> GetAllBudgetStatusesAsync(DateTime? asOfDate = null)
|
||||
{
|
||||
var date = asOfDate ?? DateTime.Today;
|
||||
var budgets = await GetAllBudgetsAsync(activeOnly: true);
|
||||
var statuses = new List<BudgetStatus>();
|
||||
|
||||
foreach (var budget in budgets)
|
||||
{
|
||||
var status = await CalculateBudgetStatusAsync(budget, date);
|
||||
statuses.Add(status);
|
||||
}
|
||||
|
||||
return statuses;
|
||||
}
|
||||
|
||||
public async Task<BudgetStatus?> 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<BudgetStatus> 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<List<string>> 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<List<Budget>> GetAllBudgetsAsync(bool activeOnly = true);
|
||||
Task<Budget?> GetBudgetByIdAsync(int id);
|
||||
Task<BudgetOperationResult> CreateBudgetAsync(Budget budget);
|
||||
Task<BudgetOperationResult> UpdateBudgetAsync(Budget budget);
|
||||
Task<BudgetOperationResult> DeleteBudgetAsync(int id);
|
||||
|
||||
// Budget status calculations
|
||||
Task<List<BudgetStatus>> GetAllBudgetStatusesAsync(DateTime? asOfDate = null);
|
||||
Task<BudgetStatus?> GetBudgetStatusAsync(int budgetId, DateTime? asOfDate = null);
|
||||
|
||||
// Helper methods
|
||||
Task<List<string>> 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<List<Budget>> 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<Budget?> GetBudgetByIdAsync(int id)
|
||||
{
|
||||
return await _db.Budgets.FindAsync(id);
|
||||
}
|
||||
|
||||
public async Task<BudgetOperationResult> 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<BudgetOperationResult> 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<BudgetOperationResult> 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<List<BudgetStatus>> GetAllBudgetStatusesAsync(DateTime? asOfDate = null)
|
||||
{
|
||||
var date = asOfDate ?? DateTime.Today;
|
||||
var budgets = await GetAllBudgetsAsync(activeOnly: true);
|
||||
var statuses = new List<BudgetStatus>();
|
||||
|
||||
foreach (var budget in budgets)
|
||||
{
|
||||
var status = await CalculateBudgetStatusAsync(budget, date);
|
||||
statuses.Add(status);
|
||||
}
|
||||
|
||||
return statuses;
|
||||
}
|
||||
|
||||
public async Task<BudgetStatus?> 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<BudgetStatus> 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<List<string>> 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}";
|
||||
}
|
||||
|
||||
@@ -1,158 +1,158 @@
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models.Import;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace MoneyMap.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for resolving payment methods (cards/accounts) for transactions.
|
||||
/// </summary>
|
||||
public interface ICardResolver
|
||||
{
|
||||
Task<PaymentResolutionResult> ResolvePaymentAsync(string? memo, ImportContext context);
|
||||
}
|
||||
|
||||
public class CardResolver : ICardResolver
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public CardResolver(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<PaymentResolutionResult> 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<PaymentResolutionResult> 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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Utility class for extracting card/account identifiers from memos and filenames.
|
||||
/// </summary>
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for resolving payment methods (cards/accounts) for transactions.
|
||||
/// </summary>
|
||||
public interface ICardResolver
|
||||
{
|
||||
Task<PaymentResolutionResult> ResolvePaymentAsync(string? memo, ImportContext context);
|
||||
}
|
||||
|
||||
public class CardResolver : ICardResolver
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public CardResolver(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<PaymentResolutionResult> 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<PaymentResolutionResult> 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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Utility class for extracting card/account identifiers from memos and filenames.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,123 +1,123 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
|
||||
namespace MoneyMap.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for card management including retrieval, validation, and deletion.
|
||||
/// </summary>
|
||||
public interface ICardService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a card by ID with optional related data.
|
||||
/// </summary>
|
||||
Task<Card?> GetCardByIdAsync(int id, bool includeRelated = false);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all cards with transaction statistics.
|
||||
/// </summary>
|
||||
Task<List<CardWithStats>> GetAllCardsWithStatsAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a card can be deleted (no transactions exist).
|
||||
/// </summary>
|
||||
Task<DeleteValidationResult> CanDeleteCardAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a card if it has no associated transactions.
|
||||
/// </summary>
|
||||
Task<DeleteResult> DeleteCardAsync(int id);
|
||||
}
|
||||
|
||||
public class CardService : ICardService
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public CardService(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<Card?> 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<List<CardWithStats>> 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<DeleteValidationResult> 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<DeleteResult> 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;
|
||||
|
||||
/// <summary>
|
||||
/// Service for card management including retrieval, validation, and deletion.
|
||||
/// </summary>
|
||||
public interface ICardService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a card by ID with optional related data.
|
||||
/// </summary>
|
||||
Task<Card?> GetCardByIdAsync(int id, bool includeRelated = false);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all cards with transaction statistics.
|
||||
/// </summary>
|
||||
Task<List<CardWithStats>> GetAllCardsWithStatsAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a card can be deleted (no transactions exist).
|
||||
/// </summary>
|
||||
Task<DeleteValidationResult> CanDeleteCardAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a card if it has no associated transactions.
|
||||
/// </summary>
|
||||
Task<DeleteResult> DeleteCardAsync(int id);
|
||||
}
|
||||
|
||||
public class CardService : ICardService
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public CardService(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<Card?> 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<List<CardWithStats>> 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<DeleteValidationResult> 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<DeleteResult> 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."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,268 +1,268 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models.Dashboard;
|
||||
|
||||
namespace MoneyMap.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for retrieving dashboard data.
|
||||
/// </summary>
|
||||
public interface IDashboardService
|
||||
{
|
||||
Task<DashboardData> 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<DashboardData> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates dashboard statistics.
|
||||
/// </summary>
|
||||
public interface IDashboardStatsCalculator
|
||||
{
|
||||
Task<DashboardStats> CalculateAsync();
|
||||
}
|
||||
|
||||
public class DashboardStatsCalculator : IDashboardStatsCalculator
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public DashboardStatsCalculator(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<DashboardStats> 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<TransactionStatsInternal> 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; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides top spending categories.
|
||||
/// </summary>
|
||||
public interface ITopCategoriesProvider
|
||||
{
|
||||
Task<List<TopCategoryRow>> GetTopCategoriesAsync(int count = 8, int lastDays = 90);
|
||||
}
|
||||
|
||||
public class TopCategoriesProvider : ITopCategoriesProvider
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public TopCategoriesProvider(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<List<TopCategoryRow>> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides recent transactions.
|
||||
/// </summary>
|
||||
public interface IRecentTransactionsProvider
|
||||
{
|
||||
Task<List<RecentTransactionRow>> GetRecentTransactionsAsync(int count = 20);
|
||||
}
|
||||
|
||||
public class RecentTransactionsProvider : IRecentTransactionsProvider
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public RecentTransactionsProvider(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<List<RecentTransactionRow>> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides spending trends over time.
|
||||
/// </summary>
|
||||
public interface ISpendTrendsProvider
|
||||
{
|
||||
Task<SpendTrends> GetDailyTrendsAsync(int lastDays = 30);
|
||||
}
|
||||
|
||||
public class SpendTrendsProvider : ISpendTrendsProvider
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public SpendTrendsProvider(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<SpendTrends> 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<string>();
|
||||
var debitsAbs = new List<decimal>();
|
||||
var credits = new List<decimal>();
|
||||
var net = new List<decimal>();
|
||||
var runningBalance = new List<decimal>();
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for retrieving dashboard data.
|
||||
/// </summary>
|
||||
public interface IDashboardService
|
||||
{
|
||||
Task<DashboardData> 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<DashboardData> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates dashboard statistics.
|
||||
/// </summary>
|
||||
public interface IDashboardStatsCalculator
|
||||
{
|
||||
Task<DashboardStats> CalculateAsync();
|
||||
}
|
||||
|
||||
public class DashboardStatsCalculator : IDashboardStatsCalculator
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public DashboardStatsCalculator(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<DashboardStats> 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<TransactionStatsInternal> 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; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides top spending categories.
|
||||
/// </summary>
|
||||
public interface ITopCategoriesProvider
|
||||
{
|
||||
Task<List<TopCategoryRow>> GetTopCategoriesAsync(int count = 8, int lastDays = 90);
|
||||
}
|
||||
|
||||
public class TopCategoriesProvider : ITopCategoriesProvider
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public TopCategoriesProvider(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<List<TopCategoryRow>> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides recent transactions.
|
||||
/// </summary>
|
||||
public interface IRecentTransactionsProvider
|
||||
{
|
||||
Task<List<RecentTransactionRow>> GetRecentTransactionsAsync(int count = 20);
|
||||
}
|
||||
|
||||
public class RecentTransactionsProvider : IRecentTransactionsProvider
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public RecentTransactionsProvider(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<List<RecentTransactionRow>> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides spending trends over time.
|
||||
/// </summary>
|
||||
public interface ISpendTrendsProvider
|
||||
{
|
||||
Task<SpendTrends> GetDailyTrendsAsync(int lastDays = 30);
|
||||
}
|
||||
|
||||
public class SpendTrendsProvider : ISpendTrendsProvider
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public SpendTrendsProvider(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<SpendTrends> 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<string>();
|
||||
var debitsAbs = new List<decimal>();
|
||||
var credits = new List<decimal>();
|
||||
var net = new List<decimal>();
|
||||
var runningBalance = new List<decimal>();
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,491 +1,491 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
using MoneyMap.Models.Api;
|
||||
|
||||
namespace MoneyMap.Services;
|
||||
|
||||
public interface IFinancialAuditService
|
||||
{
|
||||
Task<FinancialAuditResponse> 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<FinancialAuditResponse> 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<AuditSummary> CalculateSummaryAsync(
|
||||
IQueryable<Transaction> 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<List<BudgetStatusDto>> 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<List<CategorySpendingDto>> GetCategorySpendingAsync(
|
||||
IQueryable<Transaction> transactions,
|
||||
List<BudgetStatusDto> 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<List<MerchantSpendingDto>> GetMerchantSpendingAsync(
|
||||
IQueryable<Transaction> 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<List<MonthlyTrendDto>> 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<List<AccountSummaryDto>> GetAccountSummariesAsync(
|
||||
IQueryable<Transaction> 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<List<TransactionDto>> GetTransactionListAsync(
|
||||
IQueryable<Transaction> 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<AuditFlagDto> GenerateAuditFlags(FinancialAuditResponse response)
|
||||
{
|
||||
var flags = new List<AuditFlagDto>();
|
||||
|
||||
// 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<FinancialAuditResponse> 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<FinancialAuditResponse> 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<AuditSummary> CalculateSummaryAsync(
|
||||
IQueryable<Transaction> 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<List<BudgetStatusDto>> 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<List<CategorySpendingDto>> GetCategorySpendingAsync(
|
||||
IQueryable<Transaction> transactions,
|
||||
List<BudgetStatusDto> 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<List<MerchantSpendingDto>> GetMerchantSpendingAsync(
|
||||
IQueryable<Transaction> 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<List<MonthlyTrendDto>> 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<List<AccountSummaryDto>> GetAccountSummariesAsync(
|
||||
IQueryable<Transaction> 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<List<TransactionDto>> GetTransactionListAsync(
|
||||
IQueryable<Transaction> 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<AuditFlagDto> GenerateAuditFlags(FinancialAuditResponse response)
|
||||
{
|
||||
var flags = new List<AuditFlagDto>();
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace MoneyMap.Services;
|
||||
|
||||
public interface IReceiptStorageOptions
|
||||
{
|
||||
string ReceiptsBasePath { get; }
|
||||
}
|
||||
namespace MoneyMap.Services;
|
||||
|
||||
public interface IReceiptStorageOptions
|
||||
{
|
||||
string ReceiptsBasePath { get; }
|
||||
}
|
||||
|
||||
@@ -1,184 +1,184 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
|
||||
namespace MoneyMap.Services
|
||||
{
|
||||
public interface IMerchantService
|
||||
{
|
||||
Task<Merchant?> FindByNameAsync(string name);
|
||||
Task<Merchant> GetOrCreateAsync(string name);
|
||||
Task<int?> GetOrCreateIdAsync(string? name);
|
||||
Task<Merchant?> GetMerchantByIdAsync(int id, bool includeRelated = false);
|
||||
Task<List<MerchantWithStats>> GetAllMerchantsWithStatsAsync();
|
||||
Task<MerchantUpdateResult> UpdateMerchantAsync(int id, string newName);
|
||||
Task<MerchantDeleteResult> DeleteMerchantAsync(int id);
|
||||
}
|
||||
|
||||
public class MerchantService : IMerchantService
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public MerchantService(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<Merchant?> FindByNameAsync(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return null;
|
||||
|
||||
return await _db.Merchants
|
||||
.FirstOrDefaultAsync(m => m.Name == name.Trim());
|
||||
}
|
||||
|
||||
public async Task<Merchant> 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<int?> GetOrCreateIdAsync(string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return null;
|
||||
|
||||
var merchant = await GetOrCreateAsync(name);
|
||||
return merchant.Id;
|
||||
}
|
||||
|
||||
public async Task<Merchant?> 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<List<MerchantWithStats>> 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<MerchantUpdateResult> 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<MerchantDeleteResult> 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<Merchant?> FindByNameAsync(string name);
|
||||
Task<Merchant> GetOrCreateAsync(string name);
|
||||
Task<int?> GetOrCreateIdAsync(string? name);
|
||||
Task<Merchant?> GetMerchantByIdAsync(int id, bool includeRelated = false);
|
||||
Task<List<MerchantWithStats>> GetAllMerchantsWithStatsAsync();
|
||||
Task<MerchantUpdateResult> UpdateMerchantAsync(int id, string newName);
|
||||
Task<MerchantDeleteResult> DeleteMerchantAsync(int id);
|
||||
}
|
||||
|
||||
public class MerchantService : IMerchantService
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public MerchantService(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<Merchant?> FindByNameAsync(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return null;
|
||||
|
||||
return await _db.Merchants
|
||||
.FirstOrDefaultAsync(m => m.Name == name.Trim());
|
||||
}
|
||||
|
||||
public async Task<Merchant> 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<int?> GetOrCreateIdAsync(string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return null;
|
||||
|
||||
var merchant = await GetOrCreateAsync(name);
|
||||
return merchant.Id;
|
||||
}
|
||||
|
||||
public async Task<Merchant?> 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<List<MerchantWithStats>> 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<MerchantUpdateResult> 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<MerchantDeleteResult> 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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,61 +1,61 @@
|
||||
using ImageMagick;
|
||||
|
||||
namespace MoneyMap.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for converting PDF files to images for AI processing.
|
||||
/// </summary>
|
||||
public interface IPdfToImageConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts the first page of a PDF to a base64-encoded PNG image.
|
||||
/// </summary>
|
||||
Task<string> ConvertFirstPageToBase64Async(string pdfPath);
|
||||
|
||||
/// <summary>
|
||||
/// Converts PDF bytes to a base64-encoded PNG image.
|
||||
/// </summary>
|
||||
Task<string> ConvertFirstPageToBase64Async(byte[] pdfBytes);
|
||||
}
|
||||
|
||||
public class PdfToImageConverter : IPdfToImageConverter
|
||||
{
|
||||
private const int DefaultDpi = 220;
|
||||
|
||||
public Task<string> ConvertFirstPageToBase64Async(string pdfPath)
|
||||
{
|
||||
var pdfBytes = File.ReadAllBytes(pdfPath);
|
||||
return ConvertFirstPageToBase64Async(pdfBytes);
|
||||
}
|
||||
|
||||
public Task<string> 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
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for converting PDF files to images for AI processing.
|
||||
/// </summary>
|
||||
public interface IPdfToImageConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts the first page of a PDF to a base64-encoded PNG image.
|
||||
/// </summary>
|
||||
Task<string> ConvertFirstPageToBase64Async(string pdfPath);
|
||||
|
||||
/// <summary>
|
||||
/// Converts PDF bytes to a base64-encoded PNG image.
|
||||
/// </summary>
|
||||
Task<string> ConvertFirstPageToBase64Async(byte[] pdfBytes);
|
||||
}
|
||||
|
||||
public class PdfToImageConverter : IPdfToImageConverter
|
||||
{
|
||||
private const int DefaultDpi = 220;
|
||||
|
||||
public Task<string> ConvertFirstPageToBase64Async(string pdfPath)
|
||||
{
|
||||
var pdfBytes = File.ReadAllBytes(pdfPath);
|
||||
return ConvertFirstPageToBase64Async(pdfBytes);
|
||||
}
|
||||
|
||||
public Task<string> 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ReceiptAutoMapResult> AutoMapReceiptAsync(long receiptId);
|
||||
Task<BulkAutoMapResult> AutoMapUnmappedReceiptsAsync();
|
||||
Task<List<ScoredCandidate>> GetScoredCandidatesAsync(long receiptId);
|
||||
}
|
||||
|
||||
public class ReceiptAutoMapper : IReceiptAutoMapper
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
private readonly IReceiptManager _receiptManager;
|
||||
private readonly LlamaCppVisionClient _llmClient;
|
||||
private readonly ILogger<ReceiptAutoMapper> _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<ReceiptAutoMapper> logger)
|
||||
{
|
||||
_db = db;
|
||||
_receiptManager = receiptManager;
|
||||
_llmClient = llmClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ReceiptAutoMapResult> 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<BulkAutoMapResult> 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<List<ScoredCandidate>> GetScoredCandidatesAsync(long receiptId)
|
||||
{
|
||||
var receipt = await _db.Receipts
|
||||
.FirstOrDefaultAsync(r => r.Id == receiptId);
|
||||
|
||||
if (receipt == null)
|
||||
return new List<ScoredCandidate>();
|
||||
|
||||
return await FindAndScoreCandidatesAsync(receipt);
|
||||
}
|
||||
|
||||
private async Task<List<ScoredCandidate>> 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<ScoredCandidate>();
|
||||
}
|
||||
|
||||
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<string> ExtractWords(string text)
|
||||
{
|
||||
return text
|
||||
.Split(new[] { ' ', '-', '_', '.', ',', '#', '/', '\\', '*' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Where(w => w.Length > 1) // Skip single chars
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
private static double CalculateWordMatchRatio(HashSet<string> words1, HashSet<string> 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<LlmMatchResult?> GetLlmMatchDecisionAsync(Receipt receipt, List<ScoredCandidate> 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<ScoredCandidate> 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\": \"<brief explanation>\"");
|
||||
sb.AppendLine("}");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private LlmMatchResult? ParseLlmResponse(string? content, List<ScoredCandidate> candidates)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Deserialize<JsonElement>(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<Transaction> 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<Transaction> 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<ReceiptAutoMapResult> AutoMapReceiptAsync(long receiptId);
|
||||
Task<BulkAutoMapResult> AutoMapUnmappedReceiptsAsync();
|
||||
Task<List<ScoredCandidate>> GetScoredCandidatesAsync(long receiptId);
|
||||
}
|
||||
|
||||
public class ReceiptAutoMapper : IReceiptAutoMapper
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
private readonly IReceiptManager _receiptManager;
|
||||
private readonly LlamaCppVisionClient _llmClient;
|
||||
private readonly ILogger<ReceiptAutoMapper> _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<ReceiptAutoMapper> logger)
|
||||
{
|
||||
_db = db;
|
||||
_receiptManager = receiptManager;
|
||||
_llmClient = llmClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ReceiptAutoMapResult> 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<BulkAutoMapResult> 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<List<ScoredCandidate>> GetScoredCandidatesAsync(long receiptId)
|
||||
{
|
||||
var receipt = await _db.Receipts
|
||||
.FirstOrDefaultAsync(r => r.Id == receiptId);
|
||||
|
||||
if (receipt == null)
|
||||
return new List<ScoredCandidate>();
|
||||
|
||||
return await FindAndScoreCandidatesAsync(receipt);
|
||||
}
|
||||
|
||||
private async Task<List<ScoredCandidate>> 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<ScoredCandidate>();
|
||||
}
|
||||
|
||||
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<string> ExtractWords(string text)
|
||||
{
|
||||
return text
|
||||
.Split(new[] { ' ', '-', '_', '.', ',', '#', '/', '\\', '*' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Where(w => w.Length > 1) // Skip single chars
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
private static double CalculateWordMatchRatio(HashSet<string> words1, HashSet<string> 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<LlmMatchResult?> GetLlmMatchDecisionAsync(Receipt receipt, List<ScoredCandidate> 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<ScoredCandidate> 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\": \"<brief explanation>\"");
|
||||
sb.AppendLine("}");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private LlmMatchResult? ParseLlmResponse(string? content, List<ScoredCandidate> candidates)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Deserialize<JsonElement>(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<Transaction> 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<Transaction> 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ReceiptUploadResult> UploadReceiptAsync(long transactionId, IFormFile file);
|
||||
Task<ReceiptUploadResult> UploadUnmappedReceiptAsync(IFormFile file);
|
||||
Task<BulkUploadResult> UploadManyUnmappedReceiptsAsync(IReadOnlyList<IFormFile> files);
|
||||
Task<bool> DeleteReceiptAsync(long receiptId);
|
||||
Task<bool> MapReceiptToTransactionAsync(long receiptId, long transactionId);
|
||||
Task<bool> UnmapReceiptAsync(long receiptId);
|
||||
string GetReceiptPhysicalPath(Receipt receipt);
|
||||
Task<Receipt?> 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<ReceiptManager> _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<string, byte[][]> 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<ReceiptManager> logger)
|
||||
{
|
||||
_db = db;
|
||||
_receiptStorage = receiptStorage;
|
||||
_serviceProvider = serviceProvider;
|
||||
_parseQueue = parseQueue;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private string GetReceiptsBasePath()
|
||||
{
|
||||
return _receiptStorage.ReceiptsBasePath;
|
||||
}
|
||||
|
||||
public async Task<ReceiptUploadResult> 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<ReceiptUploadResult> UploadUnmappedReceiptAsync(IFormFile file)
|
||||
{
|
||||
return await UploadReceiptInternalAsync(file, null);
|
||||
}
|
||||
|
||||
private async Task<ReceiptUploadResult> 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<BulkUploadResult> UploadManyUnmappedReceiptsAsync(IReadOnlyList<IFormFile> files)
|
||||
{
|
||||
var uploaded = new List<BulkUploadItem>();
|
||||
var failed = new List<BulkUploadFailure>();
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
var result = await UploadReceiptInternalAsync(file, null);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
uploaded.Add(new BulkUploadItem
|
||||
{
|
||||
ReceiptId = result.Receipt!.Id,
|
||||
FileName = result.Receipt.FileName,
|
||||
DuplicateWarnings = result.DuplicateWarnings
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
failed.Add(new BulkUploadFailure
|
||||
{
|
||||
FileName = file.FileName,
|
||||
ErrorMessage = result.ErrorMessage ?? "Unknown error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new BulkUploadResult
|
||||
{
|
||||
Uploaded = uploaded,
|
||||
Failed = failed
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<List<DuplicateWarning>> CheckForDuplicatesAsync(string fileHash, string fileName, long fileSize)
|
||||
{
|
||||
var warnings = new List<DuplicateWarning>();
|
||||
|
||||
// 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<bool> 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<bool> 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<bool> 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<bool> 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<Receipt?> 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<DuplicateWarning> DuplicateWarnings { get; init; } = new();
|
||||
|
||||
public static ReceiptUploadResult Success(Receipt receipt, List<DuplicateWarning>? 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<BulkUploadItem> Uploaded { get; init; } = new();
|
||||
public List<BulkUploadFailure> Failed { get; init; } = new();
|
||||
public int TotalCount => Uploaded.Count + Failed.Count;
|
||||
}
|
||||
|
||||
public class BulkUploadItem
|
||||
{
|
||||
public long ReceiptId { get; set; }
|
||||
public string FileName { get; set; } = "";
|
||||
public List<DuplicateWarning> DuplicateWarnings { get; set; } = new();
|
||||
}
|
||||
|
||||
public class BulkUploadFailure
|
||||
{
|
||||
public string FileName { get; set; } = "";
|
||||
public string ErrorMessage { get; set; } = "";
|
||||
}
|
||||
}
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MoneyMap.Services
|
||||
{
|
||||
public interface IReceiptManager
|
||||
{
|
||||
Task<ReceiptUploadResult> UploadReceiptAsync(long transactionId, IFormFile file);
|
||||
Task<ReceiptUploadResult> UploadUnmappedReceiptAsync(IFormFile file);
|
||||
Task<BulkUploadResult> UploadManyUnmappedReceiptsAsync(IReadOnlyList<IFormFile> files);
|
||||
Task<bool> DeleteReceiptAsync(long receiptId);
|
||||
Task<bool> MapReceiptToTransactionAsync(long receiptId, long transactionId);
|
||||
Task<bool> UnmapReceiptAsync(long receiptId);
|
||||
string GetReceiptPhysicalPath(Receipt receipt);
|
||||
Task<Receipt?> 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<ReceiptManager> _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<string, byte[][]> 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<ReceiptManager> logger)
|
||||
{
|
||||
_db = db;
|
||||
_receiptStorage = receiptStorage;
|
||||
_serviceProvider = serviceProvider;
|
||||
_parseQueue = parseQueue;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private string GetReceiptsBasePath()
|
||||
{
|
||||
return _receiptStorage.ReceiptsBasePath;
|
||||
}
|
||||
|
||||
public async Task<ReceiptUploadResult> 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<ReceiptUploadResult> UploadUnmappedReceiptAsync(IFormFile file)
|
||||
{
|
||||
return await UploadReceiptInternalAsync(file, null);
|
||||
}
|
||||
|
||||
private async Task<ReceiptUploadResult> 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<BulkUploadResult> UploadManyUnmappedReceiptsAsync(IReadOnlyList<IFormFile> files)
|
||||
{
|
||||
var uploaded = new List<BulkUploadItem>();
|
||||
var failed = new List<BulkUploadFailure>();
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
var result = await UploadReceiptInternalAsync(file, null);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
uploaded.Add(new BulkUploadItem
|
||||
{
|
||||
ReceiptId = result.Receipt!.Id,
|
||||
FileName = result.Receipt.FileName,
|
||||
DuplicateWarnings = result.DuplicateWarnings
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
failed.Add(new BulkUploadFailure
|
||||
{
|
||||
FileName = file.FileName,
|
||||
ErrorMessage = result.ErrorMessage ?? "Unknown error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new BulkUploadResult
|
||||
{
|
||||
Uploaded = uploaded,
|
||||
Failed = failed
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<List<DuplicateWarning>> CheckForDuplicatesAsync(string fileHash, string fileName, long fileSize)
|
||||
{
|
||||
var warnings = new List<DuplicateWarning>();
|
||||
|
||||
// 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<bool> 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<bool> 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<bool> 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<bool> 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<Receipt?> 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<DuplicateWarning> DuplicateWarnings { get; init; } = new();
|
||||
|
||||
public static ReceiptUploadResult Success(Receipt receipt, List<DuplicateWarning>? 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<BulkUploadItem> Uploaded { get; init; } = new();
|
||||
public List<BulkUploadFailure> Failed { get; init; } = new();
|
||||
public int TotalCount => Uploaded.Count + Failed.Count;
|
||||
}
|
||||
|
||||
public class BulkUploadItem
|
||||
{
|
||||
public long ReceiptId { get; set; }
|
||||
public string FileName { get; set; } = "";
|
||||
public List<DuplicateWarning> DuplicateWarnings { get; set; } = new();
|
||||
}
|
||||
|
||||
public class BulkUploadFailure
|
||||
{
|
||||
public string FileName { get; set; } = "";
|
||||
public string ErrorMessage { get; set; } = "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,237 +1,237 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
|
||||
namespace MoneyMap.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for matching receipts to transactions based on date, merchant, and amount.
|
||||
/// </summary>
|
||||
public interface IReceiptMatchingService
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds matching transactions for a receipt based on date range, merchant name,
|
||||
/// and amount tolerance. Returns transactions sorted by relevance.
|
||||
/// </summary>
|
||||
Task<List<TransactionMatch>> FindMatchingTransactionsAsync(ReceiptMatchCriteria criteria);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a set of transaction IDs that already have receipts mapped to them.
|
||||
/// </summary>
|
||||
Task<HashSet<long>> GetTransactionIdsWithReceiptsAsync();
|
||||
}
|
||||
|
||||
public class ReceiptMatchingService : IReceiptMatchingService
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public ReceiptMatchingService(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<HashSet<long>> GetTransactionIdsWithReceiptsAsync()
|
||||
{
|
||||
var transactionIds = await _db.Receipts
|
||||
.Where(r => r.TransactionId != null)
|
||||
.Select(r => r.TransactionId!.Value)
|
||||
.ToListAsync();
|
||||
|
||||
return new HashSet<long>(transactionIds);
|
||||
}
|
||||
|
||||
public async Task<List<TransactionMatch>> 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<Transaction> ApplyDateFilter(IQueryable<Transaction> 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<Transaction> SortByMerchantRelevance(List<Transaction> 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<Transaction> FilterByAmountTolerance(List<Transaction> 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<TransactionMatch> ConvertToMatches(List<Transaction> 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<List<TransactionMatch>> GetFallbackMatches(HashSet<long> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Criteria for matching receipts to transactions.
|
||||
/// </summary>
|
||||
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<long> ExcludeTransactionIds { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a transaction that matches a receipt, with scoring information.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Service for matching receipts to transactions based on date, merchant, and amount.
|
||||
/// </summary>
|
||||
public interface IReceiptMatchingService
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds matching transactions for a receipt based on date range, merchant name,
|
||||
/// and amount tolerance. Returns transactions sorted by relevance.
|
||||
/// </summary>
|
||||
Task<List<TransactionMatch>> FindMatchingTransactionsAsync(ReceiptMatchCriteria criteria);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a set of transaction IDs that already have receipts mapped to them.
|
||||
/// </summary>
|
||||
Task<HashSet<long>> GetTransactionIdsWithReceiptsAsync();
|
||||
}
|
||||
|
||||
public class ReceiptMatchingService : IReceiptMatchingService
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public ReceiptMatchingService(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<HashSet<long>> GetTransactionIdsWithReceiptsAsync()
|
||||
{
|
||||
var transactionIds = await _db.Receipts
|
||||
.Where(r => r.TransactionId != null)
|
||||
.Select(r => r.TransactionId!.Value)
|
||||
.ToListAsync();
|
||||
|
||||
return new HashSet<long>(transactionIds);
|
||||
}
|
||||
|
||||
public async Task<List<TransactionMatch>> 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<Transaction> ApplyDateFilter(IQueryable<Transaction> 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<Transaction> SortByMerchantRelevance(List<Transaction> 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<Transaction> FilterByAmountTolerance(List<Transaction> 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<TransactionMatch> ConvertToMatches(List<Transaction> 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<List<TransactionMatch>> GetFallbackMatches(HashSet<long> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Criteria for matching receipts to transactions.
|
||||
/// </summary>
|
||||
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<long> ExcludeTransactionIds { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a transaction that matches a receipt, with scoring information.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace MoneyMap.Services
|
||||
{
|
||||
public interface IReceiptParseQueue
|
||||
{
|
||||
ValueTask EnqueueAsync(long receiptId, CancellationToken ct = default);
|
||||
ValueTask EnqueueManyAsync(IEnumerable<long> receiptIds, CancellationToken ct = default);
|
||||
ValueTask<long> DequeueAsync(CancellationToken ct);
|
||||
int QueueLength { get; }
|
||||
long? CurrentlyProcessingId { get; }
|
||||
void SetCurrentlyProcessing(long? receiptId);
|
||||
}
|
||||
|
||||
public class ReceiptParseQueue : IReceiptParseQueue
|
||||
{
|
||||
private readonly Channel<long> _channel = Channel.CreateUnbounded<long>(
|
||||
new UnboundedChannelOptions { SingleReader = true });
|
||||
|
||||
private long _currentlyProcessingId;
|
||||
|
||||
public int QueueLength => _channel.Reader.Count;
|
||||
|
||||
public long? CurrentlyProcessingId
|
||||
{
|
||||
get
|
||||
{
|
||||
var val = Interlocked.Read(ref _currentlyProcessingId);
|
||||
return val == 0 ? null : val;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetCurrentlyProcessing(long? receiptId)
|
||||
{
|
||||
Interlocked.Exchange(ref _currentlyProcessingId, receiptId ?? 0);
|
||||
}
|
||||
|
||||
public async ValueTask EnqueueAsync(long receiptId, CancellationToken ct = default)
|
||||
{
|
||||
await _channel.Writer.WriteAsync(receiptId, ct);
|
||||
}
|
||||
|
||||
public async ValueTask EnqueueManyAsync(IEnumerable<long> receiptIds, CancellationToken ct = default)
|
||||
{
|
||||
foreach (var id in receiptIds)
|
||||
{
|
||||
await _channel.Writer.WriteAsync(id, ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<long> DequeueAsync(CancellationToken ct)
|
||||
{
|
||||
return await _channel.Reader.ReadAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace MoneyMap.Services
|
||||
{
|
||||
public interface IReceiptParseQueue
|
||||
{
|
||||
ValueTask EnqueueAsync(long receiptId, CancellationToken ct = default);
|
||||
ValueTask EnqueueManyAsync(IEnumerable<long> receiptIds, CancellationToken ct = default);
|
||||
ValueTask<long> DequeueAsync(CancellationToken ct);
|
||||
int QueueLength { get; }
|
||||
long? CurrentlyProcessingId { get; }
|
||||
void SetCurrentlyProcessing(long? receiptId);
|
||||
}
|
||||
|
||||
public class ReceiptParseQueue : IReceiptParseQueue
|
||||
{
|
||||
private readonly Channel<long> _channel = Channel.CreateUnbounded<long>(
|
||||
new UnboundedChannelOptions { SingleReader = true });
|
||||
|
||||
private long _currentlyProcessingId;
|
||||
|
||||
public int QueueLength => _channel.Reader.Count;
|
||||
|
||||
public long? CurrentlyProcessingId
|
||||
{
|
||||
get
|
||||
{
|
||||
var val = Interlocked.Read(ref _currentlyProcessingId);
|
||||
return val == 0 ? null : val;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetCurrentlyProcessing(long? receiptId)
|
||||
{
|
||||
Interlocked.Exchange(ref _currentlyProcessingId, receiptId ?? 0);
|
||||
}
|
||||
|
||||
public async ValueTask EnqueueAsync(long receiptId, CancellationToken ct = default)
|
||||
{
|
||||
await _channel.Writer.WriteAsync(receiptId, ct);
|
||||
}
|
||||
|
||||
public async ValueTask EnqueueManyAsync(IEnumerable<long> receiptIds, CancellationToken ct = default)
|
||||
{
|
||||
foreach (var id in receiptIds)
|
||||
{
|
||||
await _channel.Writer.WriteAsync(id, ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<long> DequeueAsync(CancellationToken ct)
|
||||
{
|
||||
return await _channel.Reader.ReadAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,81 +1,81 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
|
||||
namespace MoneyMap.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for retrieving reference/lookup data used in dropdowns and filters.
|
||||
/// </summary>
|
||||
public interface IReferenceDataService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets all distinct categories from transactions, sorted alphabetically.
|
||||
/// </summary>
|
||||
Task<List<string>> GetAvailableCategoriesAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all merchants, sorted by name.
|
||||
/// </summary>
|
||||
Task<List<Merchant>> GetAvailableMerchantsAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all cards with optional account information included.
|
||||
/// </summary>
|
||||
Task<List<Card>> GetAvailableCardsAsync(bool includeAccount = true);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all accounts, sorted by institution and last4.
|
||||
/// </summary>
|
||||
Task<List<Account>> GetAvailableAccountsAsync();
|
||||
}
|
||||
|
||||
public class ReferenceDataService : IReferenceDataService
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public ReferenceDataService(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<List<string>> GetAvailableCategoriesAsync()
|
||||
{
|
||||
return await _db.Transactions
|
||||
.Select(t => t.Category ?? "")
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c))
|
||||
.Distinct()
|
||||
.OrderBy(c => c)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<Merchant>> GetAvailableMerchantsAsync()
|
||||
{
|
||||
return await _db.Merchants
|
||||
.OrderBy(m => m.Name)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<Card>> 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<List<Account>> 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;
|
||||
|
||||
/// <summary>
|
||||
/// Service for retrieving reference/lookup data used in dropdowns and filters.
|
||||
/// </summary>
|
||||
public interface IReferenceDataService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets all distinct categories from transactions, sorted alphabetically.
|
||||
/// </summary>
|
||||
Task<List<string>> GetAvailableCategoriesAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all merchants, sorted by name.
|
||||
/// </summary>
|
||||
Task<List<Merchant>> GetAvailableMerchantsAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all cards with optional account information included.
|
||||
/// </summary>
|
||||
Task<List<Card>> GetAvailableCardsAsync(bool includeAccount = true);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all accounts, sorted by institution and last4.
|
||||
/// </summary>
|
||||
Task<List<Account>> GetAvailableAccountsAsync();
|
||||
}
|
||||
|
||||
public class ReferenceDataService : IReferenceDataService
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public ReferenceDataService(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<List<string>> GetAvailableCategoriesAsync()
|
||||
{
|
||||
return await _db.Transactions
|
||||
.Select(t => t.Category ?? "")
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c))
|
||||
.Distinct()
|
||||
.OrderBy(c => c)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<Merchant>> GetAvailableMerchantsAsync()
|
||||
{
|
||||
return await _db.Merchants
|
||||
.OrderBy(m => m.Name)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<Card>> 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<List<Account>> GetAvailableAccountsAsync()
|
||||
{
|
||||
return await _db.Accounts
|
||||
.OrderBy(a => a.Institution)
|
||||
.ThenBy(a => a.Last4)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AICategoryProposal?> ProposeCategorizationAsync(Transaction transaction, string? model = null);
|
||||
Task<List<AICategoryProposal>> ProposeBatchCategorizationAsync(List<Transaction> transactions, string? model = null);
|
||||
Task<ApplyProposalResult> 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<TransactionAICategorizer> _logger;
|
||||
|
||||
public TransactionAICategorizer(
|
||||
HttpClient httpClient,
|
||||
MoneyMapContext db,
|
||||
IConfiguration config,
|
||||
LlamaCppVisionClient llamaClient,
|
||||
ILogger<TransactionAICategorizer> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_db = db;
|
||||
_config = config;
|
||||
_llamaClient = llamaClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<AICategoryProposal?> 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<List<AICategoryProposal>> ProposeBatchCategorizationAsync(List<Transaction> transactions, string? model = null)
|
||||
{
|
||||
var proposals = new List<AICategoryProposal>();
|
||||
|
||||
// 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<AICategoryProposal?> ProposeCategorizationWithCategoriesAsync(
|
||||
Transaction transaction,
|
||||
List<string> existingCategories,
|
||||
List<CategoryMapping> 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<ApplyProposalResult> 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<string> 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<string> existingCategories)
|
||||
{
|
||||
return BuildPromptWithCategoriesAndRules(transaction, existingCategories, new List<CategoryMapping>());
|
||||
}
|
||||
|
||||
private string BuildPromptWithCategoriesAndRules(Transaction transaction, List<string> existingCategories, List<CategoryMapping> matchingRules)
|
||||
{
|
||||
var categoryList = existingCategories.Any()
|
||||
? string.Join(", ", existingCategories)
|
||||
: "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<AICategorizationResponse?> CallModelAsync(string prompt, string model)
|
||||
{
|
||||
if (model.StartsWith("llamacpp:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("Using LlamaCpp for transaction categorization with model {Model}", model);
|
||||
return await CallLlamaCppAsync(prompt, model);
|
||||
}
|
||||
|
||||
// Default to OpenAI
|
||||
var apiKey = _config["OpenAI:ApiKey"] ?? Environment.GetEnvironmentVariable("OPENAI_API_KEY");
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
_logger.LogWarning("OpenAI API key not configured");
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Using OpenAI for transaction categorization with model {Model}", model);
|
||||
return await CallOpenAIAsync(apiKey, prompt, model);
|
||||
}
|
||||
|
||||
private async Task<AICategorizationResponse?> CallOpenAIAsync(string apiKey, string prompt, string model = "gpt-4o-mini")
|
||||
{
|
||||
try
|
||||
{
|
||||
var requestBody = new
|
||||
{
|
||||
model = 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<OpenAIChatResponse>(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<AICategorizationResponse>(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<AICategorizationResponse?> 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<AICategorizationResponse>(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<AICategoryProposal?> ProposeCategorizationAsync(Transaction transaction, string? model = null);
|
||||
Task<List<AICategoryProposal>> ProposeBatchCategorizationAsync(List<Transaction> transactions, string? model = null);
|
||||
Task<ApplyProposalResult> 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<TransactionAICategorizer> _logger;
|
||||
|
||||
public TransactionAICategorizer(
|
||||
HttpClient httpClient,
|
||||
MoneyMapContext db,
|
||||
IConfiguration config,
|
||||
LlamaCppVisionClient llamaClient,
|
||||
ILogger<TransactionAICategorizer> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_db = db;
|
||||
_config = config;
|
||||
_llamaClient = llamaClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<AICategoryProposal?> 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<List<AICategoryProposal>> ProposeBatchCategorizationAsync(List<Transaction> transactions, string? model = null)
|
||||
{
|
||||
var proposals = new List<AICategoryProposal>();
|
||||
|
||||
// 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<AICategoryProposal?> ProposeCategorizationWithCategoriesAsync(
|
||||
Transaction transaction,
|
||||
List<string> existingCategories,
|
||||
List<CategoryMapping> 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<ApplyProposalResult> 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<string> 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<string> existingCategories)
|
||||
{
|
||||
return BuildPromptWithCategoriesAndRules(transaction, existingCategories, new List<CategoryMapping>());
|
||||
}
|
||||
|
||||
private string BuildPromptWithCategoriesAndRules(Transaction transaction, List<string> existingCategories, List<CategoryMapping> matchingRules)
|
||||
{
|
||||
var categoryList = existingCategories.Any()
|
||||
? string.Join(", ", existingCategories)
|
||||
: "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<AICategorizationResponse?> CallModelAsync(string prompt, string model)
|
||||
{
|
||||
if (model.StartsWith("llamacpp:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("Using LlamaCpp for transaction categorization with model {Model}", model);
|
||||
return await CallLlamaCppAsync(prompt, model);
|
||||
}
|
||||
|
||||
// Default to OpenAI
|
||||
var apiKey = _config["OpenAI:ApiKey"] ?? Environment.GetEnvironmentVariable("OPENAI_API_KEY");
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
_logger.LogWarning("OpenAI API key not configured");
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Using OpenAI for transaction categorization with model {Model}", model);
|
||||
return await CallOpenAIAsync(apiKey, prompt, model);
|
||||
}
|
||||
|
||||
private async Task<AICategorizationResponse?> CallOpenAIAsync(string apiKey, string prompt, string model = "gpt-4o-mini")
|
||||
{
|
||||
try
|
||||
{
|
||||
var requestBody = new
|
||||
{
|
||||
model = 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<OpenAIChatResponse>(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<AICategorizationResponse>(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<AICategorizationResponse?> 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<AICategorizationResponse>(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; }
|
||||
}
|
||||
|
||||
@@ -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<CategorizationResult> CategorizeAsync(string merchantName, decimal? amount = null);
|
||||
Task<List<CategoryMapping>> 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<CategorizationResult> 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<List<CategoryMapping>> GetCachedMappingsAsync()
|
||||
{
|
||||
if (_cache.TryGetValue(MappingsCacheKey, out List<CategoryMapping>? 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<List<CategoryMapping>> 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<CategoryMapping> GetDefaultMappings()
|
||||
{
|
||||
var mappings = new List<CategoryMapping>();
|
||||
|
||||
// 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<CategoryMapping> mappings, params string[] patterns)
|
||||
{
|
||||
AddMappings(category, mappings, 0, patterns);
|
||||
}
|
||||
|
||||
private static void AddMappings(string category, List<CategoryMapping> 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<CategoryMapping> 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<CategorizationResult> CategorizeAsync(string merchantName, decimal? amount = null);
|
||||
Task<List<CategoryMapping>> 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<CategorizationResult> 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<List<CategoryMapping>> GetCachedMappingsAsync()
|
||||
{
|
||||
if (_cache.TryGetValue(MappingsCacheKey, out List<CategoryMapping>? 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<List<CategoryMapping>> 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<CategoryMapping> GetDefaultMappings()
|
||||
{
|
||||
var mappings = new List<CategoryMapping>();
|
||||
|
||||
// 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<CategoryMapping> mappings, params string[] patterns)
|
||||
{
|
||||
AddMappings(category, mappings, 0, patterns);
|
||||
}
|
||||
|
||||
private static void AddMappings(string category, List<CategoryMapping> 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<CategoryMapping> CategoryMappings { get; set; }
|
||||
//
|
||||
// Then create a migration:
|
||||
// dotnet ef migrations add AddCategoryMappings
|
||||
// dotnet ef database update
|
||||
}
|
||||
@@ -1,37 +1,37 @@
|
||||
using MoneyMap.Models;
|
||||
|
||||
namespace MoneyMap.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Helper class for filtering transactions in queries
|
||||
/// </summary>
|
||||
public static class TransactionFilters
|
||||
{
|
||||
/// <summary>
|
||||
/// Categories that represent transfers between accounts, not actual spending.
|
||||
/// These should be excluded from spending reports and analytics.
|
||||
/// </summary>
|
||||
public static readonly string[] TransferCategories = new[]
|
||||
{
|
||||
"Credit Card Payment",
|
||||
"Bank Transfer",
|
||||
"Banking" // Includes ATM withdrawals, transfers, fees that offset elsewhere
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Filter to exclude transfer transactions from spending queries
|
||||
/// </summary>
|
||||
public static IQueryable<Transaction> ExcludeTransfers(this IQueryable<Transaction> query)
|
||||
{
|
||||
return query.Where(t => !TransferCategories.Contains(t.Category ?? ""));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a category represents a transfer (not actual spending)
|
||||
/// </summary>
|
||||
public static bool IsTransferCategory(string? category)
|
||||
{
|
||||
return TransferCategories.Contains(category ?? "");
|
||||
}
|
||||
}
|
||||
}
|
||||
using MoneyMap.Models;
|
||||
|
||||
namespace MoneyMap.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Helper class for filtering transactions in queries
|
||||
/// </summary>
|
||||
public static class TransactionFilters
|
||||
{
|
||||
/// <summary>
|
||||
/// Categories that represent transfers between accounts, not actual spending.
|
||||
/// These should be excluded from spending reports and analytics.
|
||||
/// </summary>
|
||||
public static readonly string[] TransferCategories = new[]
|
||||
{
|
||||
"Credit Card Payment",
|
||||
"Bank Transfer",
|
||||
"Banking" // Includes ATM withdrawals, transfers, fees that offset elsewhere
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Filter to exclude transfer transactions from spending queries
|
||||
/// </summary>
|
||||
public static IQueryable<Transaction> ExcludeTransfers(this IQueryable<Transaction> query)
|
||||
{
|
||||
return query.Where(t => !TransferCategories.Contains(t.Category ?? ""));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a category represents a transfer (not actual spending)
|
||||
/// </summary>
|
||||
public static bool IsTransferCategory(string? category)
|
||||
{
|
||||
return TransferCategories.Contains(category ?? "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for importing transactions from CSV files.
|
||||
/// </summary>
|
||||
public interface ITransactionImporter
|
||||
{
|
||||
Task<PreviewOperationResult> PreviewAsync(Stream csvStream, ImportContext context);
|
||||
Task<ImportOperationResult> ImportAsync(List<Transaction> 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<PreviewOperationResult> PreviewAsync(Stream csvStream, ImportContext context)
|
||||
{
|
||||
var previewItems = new List<TransactionPreview>();
|
||||
var addedInThisBatch = new HashSet<TransactionKey>();
|
||||
|
||||
// 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<TransactionCsvRow>();
|
||||
|
||||
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<TransactionKey> 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<TransactionKey>();
|
||||
}
|
||||
|
||||
// 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<ImportOperationResult> ImportAsync(List<Transaction> 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
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for importing transactions from CSV files.
|
||||
/// </summary>
|
||||
public interface ITransactionImporter
|
||||
{
|
||||
Task<PreviewOperationResult> PreviewAsync(Stream csvStream, ImportContext context);
|
||||
Task<ImportOperationResult> ImportAsync(List<Transaction> 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<PreviewOperationResult> PreviewAsync(Stream csvStream, ImportContext context)
|
||||
{
|
||||
var previewItems = new List<TransactionPreview>();
|
||||
var addedInThisBatch = new HashSet<TransactionKey>();
|
||||
|
||||
// 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<TransactionCsvRow>();
|
||||
|
||||
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<TransactionKey> 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<TransactionKey>();
|
||||
}
|
||||
|
||||
// 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<ImportOperationResult> ImportAsync(List<Transaction> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,79 +1,79 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
|
||||
namespace MoneyMap.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for core transaction operations including duplicate detection,
|
||||
/// retrieval, and deletion.
|
||||
/// </summary>
|
||||
public interface ITransactionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if a transaction is a duplicate based on date, amount, name, memo,
|
||||
/// account, and card.
|
||||
/// </summary>
|
||||
Task<bool> IsDuplicateAsync(Transaction transaction);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a transaction by ID with optional related data.
|
||||
/// </summary>
|
||||
Task<Transaction?> GetTransactionByIdAsync(long id, bool includeRelated = false);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a transaction and all related data (receipts, parse logs, line items).
|
||||
/// </summary>
|
||||
Task<bool> DeleteTransactionAsync(long id);
|
||||
}
|
||||
|
||||
public class TransactionService : ITransactionService
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public TransactionService(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<bool> 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<Transaction?> 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<bool> 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;
|
||||
|
||||
/// <summary>
|
||||
/// Service for core transaction operations including duplicate detection,
|
||||
/// retrieval, and deletion.
|
||||
/// </summary>
|
||||
public interface ITransactionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if a transaction is a duplicate based on date, amount, name, memo,
|
||||
/// account, and card.
|
||||
/// </summary>
|
||||
Task<bool> IsDuplicateAsync(Transaction transaction);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a transaction by ID with optional related data.
|
||||
/// </summary>
|
||||
Task<Transaction?> GetTransactionByIdAsync(long id, bool includeRelated = false);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a transaction and all related data (receipts, parse logs, line items).
|
||||
/// </summary>
|
||||
Task<bool> DeleteTransactionAsync(long id);
|
||||
}
|
||||
|
||||
public class TransactionService : ITransactionService
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public TransactionService(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<bool> 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<Transaction?> 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<bool> DeleteTransactionAsync(long id)
|
||||
{
|
||||
var transaction = await _db.Transactions.FindAsync(id);
|
||||
if (transaction == null)
|
||||
return false;
|
||||
|
||||
_db.Transactions.Remove(transaction);
|
||||
await _db.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,105 +1,105 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
|
||||
namespace MoneyMap.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for calculating transaction statistics and aggregates.
|
||||
/// </summary>
|
||||
public interface ITransactionStatisticsService
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates statistics for a filtered set of transactions.
|
||||
/// </summary>
|
||||
Task<TransactionStats> CalculateStatsAsync(IQueryable<Transaction> query);
|
||||
|
||||
/// <summary>
|
||||
/// Gets categorization statistics for the entire database.
|
||||
/// </summary>
|
||||
Task<CategorizationStats> GetCategorizationStatsAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Gets card statistics for a specific account.
|
||||
/// </summary>
|
||||
Task<List<CardStats>> GetCardStatsForAccountAsync(int accountId);
|
||||
}
|
||||
|
||||
public class TransactionStatisticsService : ITransactionStatisticsService
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public TransactionStatisticsService(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<TransactionStats> CalculateStatsAsync(IQueryable<Transaction> 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<CategorizationStats> 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<List<CardStats>> 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;
|
||||
|
||||
/// <summary>
|
||||
/// Service for calculating transaction statistics and aggregates.
|
||||
/// </summary>
|
||||
public interface ITransactionStatisticsService
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates statistics for a filtered set of transactions.
|
||||
/// </summary>
|
||||
Task<TransactionStats> CalculateStatsAsync(IQueryable<Transaction> query);
|
||||
|
||||
/// <summary>
|
||||
/// Gets categorization statistics for the entire database.
|
||||
/// </summary>
|
||||
Task<CategorizationStats> GetCategorizationStatsAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Gets card statistics for a specific account.
|
||||
/// </summary>
|
||||
Task<List<CardStats>> GetCardStatsForAccountAsync(int accountId);
|
||||
}
|
||||
|
||||
public class TransactionStatisticsService : ITransactionStatisticsService
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public TransactionStatisticsService(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<TransactionStats> CalculateStatsAsync(IQueryable<Transaction> 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<CategorizationStats> 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<List<CardStats>> 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; }
|
||||
}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MoneyMap\MoneyMap.csproj" />
|
||||
<ProjectReference Include="..\MoneyMap.Core\MoneyMap.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MoneyMap\MoneyMap.csproj" />
|
||||
<ProjectReference Include="..\MoneyMap.Core\MoneyMap.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<long>()
|
||||
};
|
||||
|
||||
// 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<long>()
|
||||
};
|
||||
|
||||
// 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<long>()
|
||||
};
|
||||
|
||||
// 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<long> { 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<long>()
|
||||
};
|
||||
|
||||
// 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<long>()
|
||||
};
|
||||
|
||||
// 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<long>()
|
||||
};
|
||||
|
||||
// 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<long>()
|
||||
};
|
||||
|
||||
// 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<long> { 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<long>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await service.FindMatchingTransactionsAsync(criteria);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal(1, result[0].Id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
|
||||
namespace MoneyMap.Tests.TestHelpers;
|
||||
|
||||
public static class DbContextHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an in-memory database context for testing.
|
||||
/// Each call creates a unique database to ensure test isolation.
|
||||
/// </summary>
|
||||
public static MoneyMapContext CreateInMemoryContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<MoneyMapContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
return new MoneyMapContext(options);
|
||||
}
|
||||
}
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
|
||||
namespace MoneyMap.Tests.TestHelpers;
|
||||
|
||||
public static class DbContextHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an in-memory database context for testing.
|
||||
/// Each call creates a unique database to ensure test isolation.
|
||||
/// </summary>
|
||||
public static MoneyMapContext CreateInMemoryContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<MoneyMapContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
return new MoneyMapContext(options);
|
||||
}
|
||||
}
|
||||
|
||||
+79
-79
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
bin/
|
||||
obj/
|
||||
.vs/
|
||||
*.user
|
||||
*.suo
|
||||
appsettings.Development.json
|
||||
wwwroot/receipts/
|
||||
bin/
|
||||
obj/
|
||||
.vs/
|
||||
*.user
|
||||
*.suo
|
||||
appsettings.Development.json
|
||||
wwwroot/receipts/
|
||||
|
||||
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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; }
|
||||
}
|
||||
@@ -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}")]
|
||||
|
||||
+352
-352
@@ -1,352 +1,352 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using MoneyMap.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
[DbContext(typeof(MoneyMapContext))]
|
||||
[Migration("20251004000603_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Issuer")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Last4")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4)
|
||||
.HasColumnType("nvarchar(4)");
|
||||
|
||||
b.Property<string>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)")
|
||||
.HasDefaultValue("application/octet-stream");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)");
|
||||
|
||||
b.Property<string>("FileHashSha256")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(260)
|
||||
.HasColumnType("nvarchar(260)");
|
||||
|
||||
b.Property<long>("FileSizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Merchant")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<DateTime?>("ReceiptDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("StoragePath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("nvarchar(1024)");
|
||||
|
||||
b.Property<decimal?>("Subtotal")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("Tax")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("Total")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<long>("TransactionId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTime>("UploadedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TransactionId", "FileHashSha256")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Receipts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Category")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<decimal?>("Confidence")
|
||||
.HasColumnType("decimal(5,4)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("nvarchar(300)");
|
||||
|
||||
b.Property<int>("LineNumber")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal?>("LineTotal")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("Quantity")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<long>("ReceiptId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Sku")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("nvarchar(16)");
|
||||
|
||||
b.Property<decimal?>("UnitPrice")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReceiptId", "LineNumber");
|
||||
|
||||
b.ToTable("ReceiptLineItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime?>("CompletedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal?>("Confidence")
|
||||
.HasColumnType("decimal(5,4)");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ExtractedTextPath")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("nvarchar(1024)");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("ProviderJobId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("RawProviderPayloadJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<long>("ReceiptId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTime>("StartedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("Success")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReceiptId", "StartedAtUtc");
|
||||
|
||||
b.ToTable("ReceiptParseLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int>("CardId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("CardLast4")
|
||||
.HasMaxLength(4)
|
||||
.HasColumnType("nvarchar(4)");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<DateTime>("Date")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Memo")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using MoneyMap.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
[DbContext(typeof(MoneyMapContext))]
|
||||
[Migration("20251004000603_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Issuer")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Last4")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4)
|
||||
.HasColumnType("nvarchar(4)");
|
||||
|
||||
b.Property<string>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)")
|
||||
.HasDefaultValue("application/octet-stream");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)");
|
||||
|
||||
b.Property<string>("FileHashSha256")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(260)
|
||||
.HasColumnType("nvarchar(260)");
|
||||
|
||||
b.Property<long>("FileSizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Merchant")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<DateTime?>("ReceiptDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("StoragePath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("nvarchar(1024)");
|
||||
|
||||
b.Property<decimal?>("Subtotal")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("Tax")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("Total")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<long>("TransactionId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTime>("UploadedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TransactionId", "FileHashSha256")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Receipts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Category")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<decimal?>("Confidence")
|
||||
.HasColumnType("decimal(5,4)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("nvarchar(300)");
|
||||
|
||||
b.Property<int>("LineNumber")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal?>("LineTotal")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("Quantity")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<long>("ReceiptId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Sku")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("nvarchar(16)");
|
||||
|
||||
b.Property<decimal?>("UnitPrice")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReceiptId", "LineNumber");
|
||||
|
||||
b.ToTable("ReceiptLineItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime?>("CompletedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal?>("Confidence")
|
||||
.HasColumnType("decimal(5,4)");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ExtractedTextPath")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("nvarchar(1024)");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("ProviderJobId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("RawProviderPayloadJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<long>("ReceiptId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTime>("StartedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("Success")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReceiptId", "StartedAtUtc");
|
||||
|
||||
b.ToTable("ReceiptParseLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int>("CardId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("CardLast4")
|
||||
.HasMaxLength(4)
|
||||
.HasColumnType("nvarchar(4)");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<DateTime>("Date")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Memo")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,209 +1,209 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Cards",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Issuer = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
Last4 = table.Column<string>(type: "nvarchar(4)", maxLength: 4, nullable: false),
|
||||
Owner = table.Column<string>(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<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Date = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
TransactionType = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
Memo = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""),
|
||||
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Category = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
CardId = table.Column<int>(type: "int", nullable: false),
|
||||
CardLast4 = table.Column<string>(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<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
TransactionId = table.Column<long>(type: "bigint", nullable: false),
|
||||
FileName = table.Column<string>(type: "nvarchar(260)", maxLength: 260, nullable: false),
|
||||
ContentType = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false, defaultValue: "application/octet-stream"),
|
||||
StoragePath = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: false),
|
||||
FileSizeBytes = table.Column<long>(type: "bigint", nullable: false),
|
||||
FileHashSha256 = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
UploadedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
Merchant = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
ReceiptDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
Subtotal = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||
Tax = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||
Total = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||
Currency = table.Column<string>(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<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
ReceiptId = table.Column<long>(type: "bigint", nullable: false),
|
||||
LineNumber = table.Column<int>(type: "int", nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: false),
|
||||
Quantity = table.Column<decimal>(type: "decimal(18,4)", nullable: true),
|
||||
Confidence = table.Column<decimal>(type: "decimal(5,4)", nullable: true),
|
||||
Unit = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: true),
|
||||
UnitPrice = table.Column<decimal>(type: "decimal(18,4)", nullable: true),
|
||||
LineTotal = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||
Sku = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
Category = table.Column<string>(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<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
ReceiptId = table.Column<long>(type: "bigint", nullable: false),
|
||||
Provider = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
Model = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
ProviderJobId = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||
StartedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
CompletedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
Success = table.Column<bool>(type: "bit", nullable: false),
|
||||
Confidence = table.Column<decimal>(type: "decimal(5,4)", nullable: true),
|
||||
RawProviderPayloadJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
ExtractedTextPath = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
|
||||
Error = table.Column<string>(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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Cards",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Issuer = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
Last4 = table.Column<string>(type: "nvarchar(4)", maxLength: 4, nullable: false),
|
||||
Owner = table.Column<string>(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<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Date = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
TransactionType = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
Memo = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""),
|
||||
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Category = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
CardId = table.Column<int>(type: "int", nullable: false),
|
||||
CardLast4 = table.Column<string>(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<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
TransactionId = table.Column<long>(type: "bigint", nullable: false),
|
||||
FileName = table.Column<string>(type: "nvarchar(260)", maxLength: 260, nullable: false),
|
||||
ContentType = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false, defaultValue: "application/octet-stream"),
|
||||
StoragePath = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: false),
|
||||
FileSizeBytes = table.Column<long>(type: "bigint", nullable: false),
|
||||
FileHashSha256 = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
UploadedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
Merchant = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
ReceiptDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
Subtotal = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||
Tax = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||
Total = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||
Currency = table.Column<string>(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<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
ReceiptId = table.Column<long>(type: "bigint", nullable: false),
|
||||
LineNumber = table.Column<int>(type: "int", nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: false),
|
||||
Quantity = table.Column<decimal>(type: "decimal(18,4)", nullable: true),
|
||||
Confidence = table.Column<decimal>(type: "decimal(5,4)", nullable: true),
|
||||
Unit = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: true),
|
||||
UnitPrice = table.Column<decimal>(type: "decimal(18,4)", nullable: true),
|
||||
LineTotal = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||
Sku = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
Category = table.Column<string>(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<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
ReceiptId = table.Column<long>(type: "bigint", nullable: false),
|
||||
Provider = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
Model = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
ProviderJobId = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||
StartedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
CompletedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
Success = table.Column<bool>(type: "bit", nullable: false),
|
||||
Confidence = table.Column<decimal>(type: "decimal(5,4)", nullable: true),
|
||||
RawProviderPayloadJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
ExtractedTextPath = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
|
||||
Error = table.Column<string>(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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+373
-373
@@ -1,373 +1,373 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using MoneyMap.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
[DbContext(typeof(MoneyMapContext))]
|
||||
[Migration("20251004023633_AddCategoryMappings")]
|
||||
partial class AddCategoryMappings
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Issuer")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Last4")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4)
|
||||
.HasColumnType("nvarchar(4)");
|
||||
|
||||
b.Property<string>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)")
|
||||
.HasDefaultValue("application/octet-stream");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)");
|
||||
|
||||
b.Property<string>("FileHashSha256")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(260)
|
||||
.HasColumnType("nvarchar(260)");
|
||||
|
||||
b.Property<long>("FileSizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Merchant")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<DateTime?>("ReceiptDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("StoragePath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("nvarchar(1024)");
|
||||
|
||||
b.Property<decimal?>("Subtotal")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("Tax")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("Total")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<long>("TransactionId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTime>("UploadedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TransactionId", "FileHashSha256")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Receipts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Category")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("nvarchar(300)");
|
||||
|
||||
b.Property<int>("LineNumber")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal?>("LineTotal")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("Quantity")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<long>("ReceiptId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Sku")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("nvarchar(16)");
|
||||
|
||||
b.Property<decimal?>("UnitPrice")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReceiptId", "LineNumber");
|
||||
|
||||
b.ToTable("ReceiptLineItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime?>("CompletedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal?>("Confidence")
|
||||
.HasColumnType("decimal(5,4)");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ExtractedTextPath")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("nvarchar(1024)");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("ProviderJobId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("RawProviderPayloadJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<long>("ReceiptId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTime>("StartedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("Success")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReceiptId", "StartedAtUtc");
|
||||
|
||||
b.ToTable("ReceiptParseLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int>("CardId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("CardLast4")
|
||||
.HasMaxLength(4)
|
||||
.HasColumnType("nvarchar(4)");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<DateTime>("Date")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Memo")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Pattern")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using MoneyMap.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
[DbContext(typeof(MoneyMapContext))]
|
||||
[Migration("20251004023633_AddCategoryMappings")]
|
||||
partial class AddCategoryMappings
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Issuer")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Last4")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4)
|
||||
.HasColumnType("nvarchar(4)");
|
||||
|
||||
b.Property<string>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)")
|
||||
.HasDefaultValue("application/octet-stream");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)");
|
||||
|
||||
b.Property<string>("FileHashSha256")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(260)
|
||||
.HasColumnType("nvarchar(260)");
|
||||
|
||||
b.Property<long>("FileSizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Merchant")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<DateTime?>("ReceiptDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("StoragePath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("nvarchar(1024)");
|
||||
|
||||
b.Property<decimal?>("Subtotal")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("Tax")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("Total")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<long>("TransactionId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTime>("UploadedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TransactionId", "FileHashSha256")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Receipts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Category")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("nvarchar(300)");
|
||||
|
||||
b.Property<int>("LineNumber")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal?>("LineTotal")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("Quantity")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<long>("ReceiptId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Sku")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("nvarchar(16)");
|
||||
|
||||
b.Property<decimal?>("UnitPrice")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReceiptId", "LineNumber");
|
||||
|
||||
b.ToTable("ReceiptLineItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime?>("CompletedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal?>("Confidence")
|
||||
.HasColumnType("decimal(5,4)");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ExtractedTextPath")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("nvarchar(1024)");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("ProviderJobId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("RawProviderPayloadJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<long>("ReceiptId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTime>("StartedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("Success")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReceiptId", "StartedAtUtc");
|
||||
|
||||
b.ToTable("ReceiptParseLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int>("CardId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("CardLast4")
|
||||
.HasMaxLength(4)
|
||||
.HasColumnType("nvarchar(4)");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<DateTime>("Date")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Memo")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Pattern")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCategoryMappings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Confidence",
|
||||
table: "ReceiptLineItems");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CategoryMappings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Category = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Pattern = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Priority = table.Column<int>(type: "int", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CategoryMappings", x => x.Id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "CategoryMappings");
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "Confidence",
|
||||
table: "ReceiptLineItems",
|
||||
type: "decimal(5,4)",
|
||||
nullable: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCategoryMappings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Confidence",
|
||||
table: "ReceiptLineItems");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CategoryMappings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Category = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Pattern = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Priority = table.Column<int>(type: "int", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CategoryMappings", x => x.Id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "CategoryMappings");
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "Confidence",
|
||||
table: "ReceiptLineItems",
|
||||
type: "decimal(5,4)",
|
||||
nullable: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+377
-377
@@ -1,377 +1,377 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using MoneyMap.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
[DbContext(typeof(MoneyMapContext))]
|
||||
[Migration("20251004034919_AddNotesToTransactions")]
|
||||
partial class AddNotesToTransactions
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Issuer")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Last4")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4)
|
||||
.HasColumnType("nvarchar(4)");
|
||||
|
||||
b.Property<string>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)")
|
||||
.HasDefaultValue("application/octet-stream");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)");
|
||||
|
||||
b.Property<string>("FileHashSha256")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(260)
|
||||
.HasColumnType("nvarchar(260)");
|
||||
|
||||
b.Property<long>("FileSizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Merchant")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<DateTime?>("ReceiptDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("StoragePath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("nvarchar(1024)");
|
||||
|
||||
b.Property<decimal?>("Subtotal")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("Tax")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("Total")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<long>("TransactionId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTime>("UploadedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TransactionId", "FileHashSha256")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Receipts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Category")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("nvarchar(300)");
|
||||
|
||||
b.Property<int>("LineNumber")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal?>("LineTotal")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("Quantity")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<long>("ReceiptId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Sku")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("nvarchar(16)");
|
||||
|
||||
b.Property<decimal?>("UnitPrice")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReceiptId", "LineNumber");
|
||||
|
||||
b.ToTable("ReceiptLineItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime?>("CompletedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal?>("Confidence")
|
||||
.HasColumnType("decimal(5,4)");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ExtractedTextPath")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("nvarchar(1024)");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("ProviderJobId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("RawProviderPayloadJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<long>("ReceiptId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTime>("StartedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("Success")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReceiptId", "StartedAtUtc");
|
||||
|
||||
b.ToTable("ReceiptParseLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int>("CardId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("CardLast4")
|
||||
.HasMaxLength(4)
|
||||
.HasColumnType("nvarchar(4)");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<DateTime>("Date")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Memo")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("TransactionType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Pattern")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using MoneyMap.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
[DbContext(typeof(MoneyMapContext))]
|
||||
[Migration("20251004034919_AddNotesToTransactions")]
|
||||
partial class AddNotesToTransactions
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Issuer")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Last4")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4)
|
||||
.HasColumnType("nvarchar(4)");
|
||||
|
||||
b.Property<string>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)")
|
||||
.HasDefaultValue("application/octet-stream");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)");
|
||||
|
||||
b.Property<string>("FileHashSha256")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(260)
|
||||
.HasColumnType("nvarchar(260)");
|
||||
|
||||
b.Property<long>("FileSizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Merchant")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<DateTime?>("ReceiptDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("StoragePath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("nvarchar(1024)");
|
||||
|
||||
b.Property<decimal?>("Subtotal")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("Tax")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("Total")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<long>("TransactionId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTime>("UploadedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TransactionId", "FileHashSha256")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Receipts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Category")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("nvarchar(300)");
|
||||
|
||||
b.Property<int>("LineNumber")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal?>("LineTotal")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("Quantity")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<long>("ReceiptId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Sku")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("nvarchar(16)");
|
||||
|
||||
b.Property<decimal?>("UnitPrice")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReceiptId", "LineNumber");
|
||||
|
||||
b.ToTable("ReceiptLineItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime?>("CompletedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal?>("Confidence")
|
||||
.HasColumnType("decimal(5,4)");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ExtractedTextPath")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("nvarchar(1024)");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("ProviderJobId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("RawProviderPayloadJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<long>("ReceiptId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTime>("StartedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("Success")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReceiptId", "StartedAtUtc");
|
||||
|
||||
b.ToTable("ReceiptParseLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int>("CardId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("CardLast4")
|
||||
.HasMaxLength(4)
|
||||
.HasColumnType("nvarchar(4)");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<DateTime>("Date")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Memo")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("TransactionType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Pattern")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddNotesToTransactions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Notes",
|
||||
table: "Transactions",
|
||||
type: "nvarchar(max)",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Notes",
|
||||
table: "Transactions");
|
||||
}
|
||||
}
|
||||
}
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddNotesToTransactions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Notes",
|
||||
table: "Transactions",
|
||||
type: "nvarchar(max)",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Notes",
|
||||
table: "Transactions");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+507
-507
File diff suppressed because it is too large
Load Diff
@@ -1,184 +1,184 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SplitCardsAndAccounts : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<int>(
|
||||
name: "CardId",
|
||||
table: "Transactions",
|
||||
type: "int",
|
||||
nullable: true,
|
||||
oldClrType: typeof(int),
|
||||
oldType: "int");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AccountId",
|
||||
table: "Transactions",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Accounts",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Institution = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
Last4 = table.Column<string>(type: "nvarchar(4)", maxLength: 4, nullable: false),
|
||||
Owner = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
AccountType = table.Column<int>(type: "int", nullable: false),
|
||||
Nickname = table.Column<string>(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<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Date = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
|
||||
Notes = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
SourceAccountId = table.Column<int>(type: "int", nullable: true),
|
||||
DestinationAccountId = table.Column<int>(type: "int", nullable: true),
|
||||
OriginalTransactionId = table.Column<long>(type: "bigint", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<int>(
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SplitCardsAndAccounts : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<int>(
|
||||
name: "CardId",
|
||||
table: "Transactions",
|
||||
type: "int",
|
||||
nullable: true,
|
||||
oldClrType: typeof(int),
|
||||
oldType: "int");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AccountId",
|
||||
table: "Transactions",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Accounts",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Institution = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
Last4 = table.Column<string>(type: "nvarchar(4)", maxLength: 4, nullable: false),
|
||||
Owner = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
AccountType = table.Column<int>(type: "int", nullable: false),
|
||||
Nickname = table.Column<string>(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<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Date = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
|
||||
Notes = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
SourceAccountId = table.Column<int>(type: "int", nullable: true),
|
||||
DestinationAccountId = table.Column<int>(type: "int", nullable: true),
|
||||
OriginalTransactionId = table.Column<long>(type: "bigint", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<int>(
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+528
-528
File diff suppressed because it is too large
Load Diff
@@ -1,60 +1,60 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class LinkCardsToAccounts : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AccountId",
|
||||
table: "Cards",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class LinkCardsToAccounts : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AccountId",
|
||||
table: "Cards",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+528
-528
File diff suppressed because it is too large
Load Diff
@@ -1,89 +1,89 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class MakeAccountRequiredOnTransaction : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<int>(
|
||||
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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Transactions_Date_Amount_Name_Memo_AccountId_CardId",
|
||||
table: "Transactions");
|
||||
|
||||
migrationBuilder.AlterColumn<int>(
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class MakeAccountRequiredOnTransaction : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<int>(
|
||||
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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Transactions_Date_Amount_Name_Memo_AccountId_CardId",
|
||||
table: "Transactions");
|
||||
|
||||
migrationBuilder.AlterColumn<int>(
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+539
-539
File diff suppressed because it is too large
Load Diff
@@ -1,48 +1,48 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTransferSupport : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTransferSupport : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+543
-543
File diff suppressed because it is too large
Load Diff
@@ -1,29 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMerchantToTransactions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Merchant",
|
||||
table: "Transactions",
|
||||
type: "nvarchar(100)",
|
||||
maxLength: 100,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Merchant",
|
||||
table: "Transactions");
|
||||
}
|
||||
}
|
||||
}
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMerchantToTransactions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Merchant",
|
||||
table: "Transactions",
|
||||
type: "nvarchar(100)",
|
||||
maxLength: 100,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Merchant",
|
||||
table: "Transactions");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+546
-546
File diff suppressed because it is too large
Load Diff
@@ -1,28 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMerchantToCategoryMappings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Merchant",
|
||||
table: "CategoryMappings",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Merchant",
|
||||
table: "CategoryMappings");
|
||||
}
|
||||
}
|
||||
}
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMerchantToCategoryMappings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Merchant",
|
||||
table: "CategoryMappings",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Merchant",
|
||||
table: "CategoryMappings");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+596
-596
File diff suppressed because it is too large
Load Diff
@@ -1,159 +1,159 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ConvertMerchantToEntity : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Merchant",
|
||||
table: "Transactions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Merchant",
|
||||
table: "CategoryMappings");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MerchantId",
|
||||
table: "Transactions",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Pattern",
|
||||
table: "CategoryMappings",
|
||||
type: "nvarchar(200)",
|
||||
maxLength: 200,
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(max)");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Category",
|
||||
table: "CategoryMappings",
|
||||
type: "nvarchar(100)",
|
||||
maxLength: 100,
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(max)");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MerchantId",
|
||||
table: "CategoryMappings",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Merchants",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<string>(
|
||||
name: "Merchant",
|
||||
table: "Transactions",
|
||||
type: "nvarchar(100)",
|
||||
maxLength: 100,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Pattern",
|
||||
table: "CategoryMappings",
|
||||
type: "nvarchar(max)",
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(200)",
|
||||
oldMaxLength: 200);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Category",
|
||||
table: "CategoryMappings",
|
||||
type: "nvarchar(max)",
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(100)",
|
||||
oldMaxLength: 100);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Merchant",
|
||||
table: "CategoryMappings",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ConvertMerchantToEntity : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Merchant",
|
||||
table: "Transactions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Merchant",
|
||||
table: "CategoryMappings");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MerchantId",
|
||||
table: "Transactions",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Pattern",
|
||||
table: "CategoryMappings",
|
||||
type: "nvarchar(200)",
|
||||
maxLength: 200,
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(max)");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Category",
|
||||
table: "CategoryMappings",
|
||||
type: "nvarchar(100)",
|
||||
maxLength: 100,
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(max)");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MerchantId",
|
||||
table: "CategoryMappings",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Merchants",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<string>(
|
||||
name: "Merchant",
|
||||
table: "Transactions",
|
||||
type: "nvarchar(100)",
|
||||
maxLength: 100,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Pattern",
|
||||
table: "CategoryMappings",
|
||||
type: "nvarchar(max)",
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(200)",
|
||||
oldMaxLength: 200);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Category",
|
||||
table: "CategoryMappings",
|
||||
type: "nvarchar(max)",
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(100)",
|
||||
oldMaxLength: 100);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Merchant",
|
||||
table: "CategoryMappings",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+606
-606
File diff suppressed because it is too large
Load Diff
@@ -1,49 +1,49 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAICategorization : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "Confidence",
|
||||
table: "CategoryMappings",
|
||||
type: "decimal(5,4)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "CreatedAt",
|
||||
table: "CategoryMappings",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CreatedBy",
|
||||
table: "CategoryMappings",
|
||||
type: "nvarchar(50)",
|
||||
maxLength: 50,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAICategorization : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "Confidence",
|
||||
table: "CategoryMappings",
|
||||
type: "decimal(5,4)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "CreatedAt",
|
||||
table: "CategoryMappings",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CreatedBy",
|
||||
table: "CategoryMappings",
|
||||
type: "nvarchar(50)",
|
||||
maxLength: 50,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Confidence",
|
||||
table: "CategoryMappings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CreatedAt",
|
||||
table: "CategoryMappings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CreatedBy",
|
||||
table: "CategoryMappings");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+606
-606
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class MakeReceiptTransactionIdNullable : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class MakeReceiptTransactionIdNullable : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+606
-606
File diff suppressed because it is too large
Load Diff
@@ -1,57 +1,57 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class UpdateReceiptIndexForNullableTransactionId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Receipts_TransactionId_FileHashSha256",
|
||||
table: "Receipts");
|
||||
|
||||
migrationBuilder.AlterColumn<long>(
|
||||
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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Receipts_TransactionId_FileHashSha256",
|
||||
table: "Receipts");
|
||||
|
||||
migrationBuilder.AlterColumn<long>(
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class UpdateReceiptIndexForNullableTransactionId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Receipts_TransactionId_FileHashSha256",
|
||||
table: "Receipts");
|
||||
|
||||
migrationBuilder.AlterColumn<long>(
|
||||
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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Receipts_TransactionId_FileHashSha256",
|
||||
table: "Receipts");
|
||||
|
||||
migrationBuilder.AlterColumn<long>(
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+606
-606
File diff suppressed because it is too large
Load Diff
@@ -1,57 +1,57 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class MakeReceiptTransactionOptional : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Receipts_TransactionId_FileHashSha256",
|
||||
table: "Receipts");
|
||||
|
||||
migrationBuilder.AlterColumn<long>(
|
||||
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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Receipts_TransactionId_FileHashSha256",
|
||||
table: "Receipts");
|
||||
|
||||
migrationBuilder.AlterColumn<long>(
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class MakeReceiptTransactionOptional : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Receipts_TransactionId_FileHashSha256",
|
||||
table: "Receipts");
|
||||
|
||||
migrationBuilder.AlterColumn<long>(
|
||||
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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Receipts_TransactionId_FileHashSha256",
|
||||
table: "Receipts");
|
||||
|
||||
migrationBuilder.AlterColumn<long>(
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+609
-609
File diff suppressed because it is too large
Load Diff
@@ -1,28 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddReceiptDueDate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "DueDate",
|
||||
table: "Receipts",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DueDate",
|
||||
table: "Receipts");
|
||||
}
|
||||
}
|
||||
}
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddReceiptDueDate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "DueDate",
|
||||
table: "Receipts",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DueDate",
|
||||
table: "Receipts");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+612
-612
File diff suppressed because it is too large
Load Diff
@@ -1,29 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddVoidedToReceiptLineItem : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "Voided",
|
||||
table: "ReceiptLineItems",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Voided",
|
||||
table: "ReceiptLineItems");
|
||||
}
|
||||
}
|
||||
}
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddVoidedToReceiptLineItem : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "Voided",
|
||||
table: "ReceiptLineItems",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Voided",
|
||||
table: "ReceiptLineItems");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user