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 build:*)",
|
||||||
"Bash(dotnet new:*)",
|
"Bash(dotnet new:*)",
|
||||||
"Bash(dotnet add:*)",
|
"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": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
+13
-13
@@ -1,13 +1,13 @@
|
|||||||
# Docker image
|
# Docker image
|
||||||
DOCKER_IMAGE=yourusername/moneymap:latest
|
DOCKER_IMAGE=yourusername/moneymap:latest
|
||||||
|
|
||||||
# Database connection
|
# Database connection
|
||||||
DB_SERVER=your-server
|
DB_SERVER=your-server
|
||||||
DB_USER=your-username
|
DB_USER=your-username
|
||||||
DB_PASSWORD=your-password
|
DB_PASSWORD=your-password
|
||||||
|
|
||||||
# OpenAI API key for receipt parsing
|
# OpenAI API key for receipt parsing
|
||||||
OPENAI_API_KEY=your-openai-key
|
OPENAI_API_KEY=your-openai-key
|
||||||
|
|
||||||
# Host path for receipt storage
|
# Host path for receipt storage
|
||||||
RECEIPTS_HOST_PATH=/mnt/docker-data/moneymap/receipts
|
RECEIPTS_HOST_PATH=/mnt/docker-data/moneymap/receipts
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ on:
|
|||||||
- "MoneyMap/**"
|
- "MoneyMap/**"
|
||||||
- "MoneyMap.Core/**"
|
- "MoneyMap.Core/**"
|
||||||
- "MoneyMap.Mcp/**"
|
- "MoneyMap.Mcp/**"
|
||||||
- "MoneyMap/Dockerfile"
|
- "Dockerfile"
|
||||||
- ".gitea/workflows/build-moneymap.yml"
|
- ".gitea/workflows/build-moneymap.yml"
|
||||||
workflow_dispatch: {}
|
workflow_dispatch: {}
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.thecozycat.net -u "${{ gitea.actor }}" --password-stdin
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.thecozycat.net -u "${{ gitea.actor }}" --password-stdin
|
||||||
|
|
||||||
- name: Build image
|
- 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
|
- name: Push image
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+48
-48
@@ -1,48 +1,48 @@
|
|||||||
|
|
||||||
#Ignore thumbnails created by Windows
|
#Ignore thumbnails created by Windows
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
#Ignore files built by Visual Studio
|
#Ignore files built by Visual Studio
|
||||||
*.obj
|
*.obj
|
||||||
*.exe
|
*.exe
|
||||||
*.pdb
|
*.pdb
|
||||||
*.user
|
*.user
|
||||||
*.aps
|
*.aps
|
||||||
*.pch
|
*.pch
|
||||||
*.vspscc
|
*.vspscc
|
||||||
*_i.c
|
*_i.c
|
||||||
*_p.c
|
*_p.c
|
||||||
*.ncb
|
*.ncb
|
||||||
*.suo
|
*.suo
|
||||||
*.tlb
|
*.tlb
|
||||||
*.tlh
|
*.tlh
|
||||||
*.bak
|
*.bak
|
||||||
*.cache
|
*.cache
|
||||||
*.ilk
|
*.ilk
|
||||||
*.log
|
*.log
|
||||||
[Bb]in
|
[Bb]in
|
||||||
[Dd]ebug*/
|
[Dd]ebug*/
|
||||||
*.lib
|
*.lib
|
||||||
*.sbr
|
*.sbr
|
||||||
obj/
|
obj/
|
||||||
[Rr]elease*/
|
[Rr]elease*/
|
||||||
_ReSharper*/
|
_ReSharper*/
|
||||||
[Tt]est[Rr]esult*
|
[Tt]est[Rr]esult*
|
||||||
.vs/
|
.vs/
|
||||||
.idea/
|
.idea/
|
||||||
#Nuget packages folder
|
#Nuget packages folder
|
||||||
packages/
|
packages/
|
||||||
/MoneyMap/wwwroot/lib/
|
/MoneyMap/wwwroot/lib/
|
||||||
/MoneyMap/wwwroot/receipts/
|
/MoneyMap/wwwroot/receipts/
|
||||||
**/publish/
|
**/publish/
|
||||||
|
|
||||||
# Environment files with secrets
|
# Environment files with secrets
|
||||||
.env
|
.env
|
||||||
|
|
||||||
# Local settings
|
# Local settings
|
||||||
settings.local.json
|
settings.local.json
|
||||||
|
|
||||||
# Superpowers plans/specs
|
# Superpowers plans/specs
|
||||||
docs/superpowers/
|
docs/superpowers/
|
||||||
|
|
||||||
# Playwright MCP artifacts
|
# Playwright MCP artifacts
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
|||||||
@@ -1,109 +1,109 @@
|
|||||||
# MoneyMap - Codex Agent Context
|
# MoneyMap - Codex Agent Context
|
||||||
|
|
||||||
## Quick Overview
|
## Quick Overview
|
||||||
|
|
||||||
MoneyMap is an ASP.NET Core 8.0 Razor Pages application for personal finance tracking. Users can import bank transaction CSVs, categorize expenses, attach receipt images/PDFs, and parse receipts using OpenAI's Vision API.
|
MoneyMap is an ASP.NET Core 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
|
## Architecture Documentation
|
||||||
|
|
||||||
**For complete technical documentation, see [ARCHITECTURE.md](./ARCHITECTURE.md)**
|
**For complete technical documentation, see [ARCHITECTURE.md](./ARCHITECTURE.md)**
|
||||||
|
|
||||||
The shared architecture document provides:
|
The shared architecture document provides:
|
||||||
- Complete technology stack and dependencies
|
- Complete technology stack and dependencies
|
||||||
- Core domain models and relationships
|
- Core domain models and relationships
|
||||||
- Service layer implementations
|
- Service layer implementations
|
||||||
- Database schema with all tables and relationships
|
- Database schema with all tables and relationships
|
||||||
- Key workflows (CSV import, categorization, receipt parsing, etc.)
|
- Key workflows (CSV import, categorization, receipt parsing, etc.)
|
||||||
- Design patterns and best practices
|
- Design patterns and best practices
|
||||||
- Security considerations
|
- Security considerations
|
||||||
- Performance optimizations
|
- Performance optimizations
|
||||||
- Troubleshooting guide
|
- Troubleshooting guide
|
||||||
|
|
||||||
## Agent Configuration
|
## Agent Configuration
|
||||||
|
|
||||||
This file is used by Codex CLI for agent context. Claude Code users should reference [CLAUDE.md](./CLAUDE.md) instead.
|
This file is used by Codex CLI for agent context. Claude Code users should reference [CLAUDE.md](./CLAUDE.md) instead.
|
||||||
|
|
||||||
## Key Features for Agents
|
## Key Features for Agents
|
||||||
|
|
||||||
### Transaction Management
|
### Transaction Management
|
||||||
- CSV import with duplicate detection
|
- CSV import with duplicate detection
|
||||||
- Auto-categorization based on merchant patterns
|
- Auto-categorization based on merchant patterns
|
||||||
- Manual category and merchant assignment
|
- Manual category and merchant assignment
|
||||||
- Transfer detection between accounts
|
- Transfer detection between accounts
|
||||||
|
|
||||||
### Receipt Processing
|
### Receipt Processing
|
||||||
- File upload with SHA256 deduplication
|
- File upload with SHA256 deduplication
|
||||||
- OpenAI Vision API integration for parsing
|
- OpenAI Vision API integration for parsing
|
||||||
- Line item extraction
|
- Line item extraction
|
||||||
- Parse logging and confidence tracking
|
- Parse logging and confidence tracking
|
||||||
|
|
||||||
### Data Organization
|
### Data Organization
|
||||||
- Account and Card hierarchy
|
- Account and Card hierarchy
|
||||||
- Merchant normalization
|
- Merchant normalization
|
||||||
- Category mapping rules with priority
|
- Category mapping rules with priority
|
||||||
- Relationship tracking (transactions ↔ receipts ↔ line items)
|
- Relationship tracking (transactions ↔ receipts ↔ line items)
|
||||||
|
|
||||||
## Common Agent Tasks
|
## Common Agent Tasks
|
||||||
|
|
||||||
### Code Analysis
|
### Code Analysis
|
||||||
When analyzing code, refer to [ARCHITECTURE.md](./ARCHITECTURE.md) for:
|
When analyzing code, refer to [ARCHITECTURE.md](./ARCHITECTURE.md) for:
|
||||||
- Service interfaces and their responsibilities
|
- Service interfaces and their responsibilities
|
||||||
- Domain model relationships
|
- Domain model relationships
|
||||||
- Database constraints and cascade rules
|
- Database constraints and cascade rules
|
||||||
- Performance considerations (indexes, AsNoTracking)
|
- Performance considerations (indexes, AsNoTracking)
|
||||||
|
|
||||||
### Feature Implementation
|
### Feature Implementation
|
||||||
1. Check [ARCHITECTURE.md](./ARCHITECTURE.md) for existing patterns
|
1. Check [ARCHITECTURE.md](./ARCHITECTURE.md) for existing patterns
|
||||||
2. Follow Service Layer Pattern (business logic in services)
|
2. Follow Service Layer Pattern (business logic in services)
|
||||||
3. Use Result Pattern for error handling
|
3. Use Result Pattern for error handling
|
||||||
4. Register new services in Program.cs
|
4. Register new services in Program.cs
|
||||||
5. Create migrations for schema changes
|
5. Create migrations for schema changes
|
||||||
|
|
||||||
### Bug Investigation
|
### Bug Investigation
|
||||||
1. Review relevant service in [ARCHITECTURE.md](./ARCHITECTURE.md)
|
1. Review relevant service in [ARCHITECTURE.md](./ARCHITECTURE.md)
|
||||||
2. Check database constraints and relationships
|
2. Check database constraints and relationships
|
||||||
3. Review cascade delete rules
|
3. Review cascade delete rules
|
||||||
4. Check unique indexes for duplicate constraints
|
4. Check unique indexes for duplicate constraints
|
||||||
|
|
||||||
### Refactoring
|
### Refactoring
|
||||||
1. Maintain service layer separation
|
1. Maintain service layer separation
|
||||||
2. Keep interfaces for testability
|
2. Keep interfaces for testability
|
||||||
3. Follow existing patterns (DI, Result Pattern, DTOs)
|
3. Follow existing patterns (DI, Result Pattern, DTOs)
|
||||||
4. Update [ARCHITECTURE.md](./ARCHITECTURE.md) if making significant changes
|
4. Update [ARCHITECTURE.md](./ARCHITECTURE.md) if making significant changes
|
||||||
|
|
||||||
## Important Constraints
|
## Important Constraints
|
||||||
|
|
||||||
- **Unique Transactions**: (Date, Amount, Name, Memo, AccountId, CardId)
|
- **Unique Transactions**: (Date, Amount, Name, Memo, AccountId, CardId)
|
||||||
- **Cascade Deletes**: Transaction → Receipts → ParseLogs/LineItems
|
- **Cascade Deletes**: Transaction → Receipts → ParseLogs/LineItems
|
||||||
- **Restrict Deletes**: Can't delete Account/Card with existing transactions
|
- **Restrict Deletes**: Can't delete Account/Card with existing transactions
|
||||||
- **File Limits**: Receipts max 10MB, whitelist extensions only
|
- **File Limits**: Receipts max 10MB, whitelist extensions only
|
||||||
- **API Requirements**: OpenAI API key required for receipt parsing
|
- **API Requirements**: OpenAI API key required for receipt parsing
|
||||||
|
|
||||||
## Development Guidelines
|
## Development Guidelines
|
||||||
|
|
||||||
1. **Always** check [ARCHITECTURE.md](./ARCHITECTURE.md) before making changes
|
1. **Always** check [ARCHITECTURE.md](./ARCHITECTURE.md) before making changes
|
||||||
2. **Use** existing service patterns and interfaces
|
2. **Use** existing service patterns and interfaces
|
||||||
3. **Follow** Single Responsibility Principle
|
3. **Follow** Single Responsibility Principle
|
||||||
4. **Test** with mockable interfaces
|
4. **Test** with mockable interfaces
|
||||||
5. **Update [ARCHITECTURE.md](./ARCHITECTURE.md) when making architectural changes**
|
5. **Update [ARCHITECTURE.md](./ARCHITECTURE.md) when making architectural changes**
|
||||||
|
|
||||||
## Important: Keep Documentation Updated
|
## Important: Keep Documentation Updated
|
||||||
|
|
||||||
**When making architectural changes, always update [ARCHITECTURE.md](./ARCHITECTURE.md):**
|
**When making architectural changes, always update [ARCHITECTURE.md](./ARCHITECTURE.md):**
|
||||||
- Adding/removing domain models (Account, Transaction, Receipt, etc.)
|
- Adding/removing domain models (Account, Transaction, Receipt, etc.)
|
||||||
- Adding/removing services or changing their responsibilities
|
- Adding/removing services or changing their responsibilities
|
||||||
- Modifying database schema, relationships, or constraints
|
- Modifying database schema, relationships, or constraints
|
||||||
- Adding new workflows or processes
|
- Adding new workflows or processes
|
||||||
- Changing design patterns or conventions
|
- Changing design patterns or conventions
|
||||||
- Adding new pages or major features
|
- Adding new pages or major features
|
||||||
- Modifying security or performance considerations
|
- Modifying security or performance considerations
|
||||||
|
|
||||||
This shared documentation ensures both Claude Code and Codex CLI have accurate context for future work.
|
This shared documentation ensures both Claude Code and Codex CLI have accurate context for future work.
|
||||||
|
|
||||||
## Reference Links
|
## Reference Links
|
||||||
|
|
||||||
- Technical Details: [ARCHITECTURE.md](./ARCHITECTURE.md)
|
- Technical Details: [ARCHITECTURE.md](./ARCHITECTURE.md)
|
||||||
- Claude Code Context: [CLAUDE.md](./CLAUDE.md)
|
- Claude Code Context: [CLAUDE.md](./CLAUDE.md)
|
||||||
- Service Layer: See ARCHITECTURE.md § Service Layer
|
- Service Layer: See ARCHITECTURE.md § Service Layer
|
||||||
- Database Schema: See ARCHITECTURE.md § Database Schema
|
- Database Schema: See ARCHITECTURE.md § Database Schema
|
||||||
- Workflows: See ARCHITECTURE.md § Key Workflows
|
- 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
|
# MoneyMap - Claude Code Context
|
||||||
|
|
||||||
## Quick Overview
|
## Quick Overview
|
||||||
|
|
||||||
MoneyMap is an ASP.NET Core 8.0 Razor Pages application for personal finance tracking. Users can import bank transaction CSVs, categorize expenses, attach receipt images/PDFs, and parse receipts using OpenAI's Vision API.
|
MoneyMap is an ASP.NET Core 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
|
## Architecture Documentation
|
||||||
|
|
||||||
**For detailed technical documentation, see [ARCHITECTURE.md](./ARCHITECTURE.md)**
|
**For detailed technical documentation, see [ARCHITECTURE.md](./ARCHITECTURE.md)**
|
||||||
|
|
||||||
The architecture document contains:
|
The architecture document contains:
|
||||||
- Complete technology stack
|
- Complete technology stack
|
||||||
- Core domain models (Transaction, Receipt, Card, Account, Merchant, etc.)
|
- Core domain models (Transaction, Receipt, Card, Account, Merchant, etc.)
|
||||||
- Service layer details (TransactionImporter, CardResolver, TransactionCategorizer, etc.)
|
- Service layer details (TransactionImporter, CardResolver, TransactionCategorizer, etc.)
|
||||||
- Database schema and relationships
|
- Database schema and relationships
|
||||||
- Key workflows and design patterns
|
- Key workflows and design patterns
|
||||||
- Security and performance considerations
|
- Security and performance considerations
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
MoneyMap/
|
MoneyMap/
|
||||||
├── Data/
|
├── Data/
|
||||||
│ └── MoneyMapContext.cs # EF Core DbContext
|
│ └── MoneyMapContext.cs # EF Core DbContext
|
||||||
├── Models/
|
├── Models/
|
||||||
│ ├── Account.cs # Bank accounts
|
│ ├── Account.cs # Bank accounts
|
||||||
│ ├── Card.cs # Payment cards
|
│ ├── Card.cs # Payment cards
|
||||||
│ ├── Merchant.cs # Merchants/vendors
|
│ ├── Merchant.cs # Merchants/vendors
|
||||||
│ ├── Transaction.cs # Core transaction entity
|
│ ├── Transaction.cs # Core transaction entity
|
||||||
│ ├── Receipt.cs # Receipt files
|
│ ├── Receipt.cs # Receipt files
|
||||||
│ ├── ReceiptLineItem.cs # Parsed line items
|
│ ├── ReceiptLineItem.cs # Parsed line items
|
||||||
│ └── ReceiptParseLog.cs # Parse attempt logs
|
│ └── ReceiptParseLog.cs # Parse attempt logs
|
||||||
├── Services/
|
├── Services/
|
||||||
│ ├── TransactionCategorizer.cs # Auto-categorization logic
|
│ ├── TransactionCategorizer.cs # Auto-categorization logic
|
||||||
│ ├── ReceiptManager.cs # Receipt upload/storage
|
│ ├── ReceiptManager.cs # Receipt upload/storage
|
||||||
│ └── OpenAIReceiptParser.cs # AI-powered receipt parsing
|
│ └── OpenAIReceiptParser.cs # AI-powered receipt parsing
|
||||||
├── Pages/
|
├── Pages/
|
||||||
│ ├── Index.cshtml[.cs] # Dashboard
|
│ ├── Index.cshtml[.cs] # Dashboard
|
||||||
│ ├── Upload.cshtml[.cs] # CSV import
|
│ ├── Upload.cshtml[.cs] # CSV import
|
||||||
│ ├── Transactions.cshtml[.cs] # Transaction list
|
│ ├── Transactions.cshtml[.cs] # Transaction list
|
||||||
│ ├── EditTransaction.cshtml[.cs] # Edit transaction
|
│ ├── EditTransaction.cshtml[.cs] # Edit transaction
|
||||||
│ ├── ViewReceipt.cshtml[.cs] # Receipt details
|
│ ├── ViewReceipt.cshtml[.cs] # Receipt details
|
||||||
│ ├── CategoryMappings.cshtml[.cs]# Category rules
|
│ ├── CategoryMappings.cshtml[.cs]# Category rules
|
||||||
│ ├── Merchants.cshtml[.cs] # Merchant management
|
│ ├── Merchants.cshtml[.cs] # Merchant management
|
||||||
│ └── Recategorize.cshtml[.cs] # Bulk recategorization
|
│ └── Recategorize.cshtml[.cs] # Bulk recategorization
|
||||||
└── Program.cs # DI configuration
|
└── Program.cs # DI configuration
|
||||||
```
|
```
|
||||||
|
|
||||||
## Common Development Tasks
|
## Common Development Tasks
|
||||||
|
|
||||||
### Adding a New Page
|
### Adding a New Page
|
||||||
1. Create `.cshtml` and `.cshtml.cs` files in `Pages/`
|
1. Create `.cshtml` and `.cshtml.cs` files in `Pages/`
|
||||||
2. Inherit from `PageModel`
|
2. Inherit from `PageModel`
|
||||||
3. Add route via `@page` directive
|
3. Add route via `@page` directive
|
||||||
4. Register dependencies in constructor via DI
|
4. Register dependencies in constructor via DI
|
||||||
|
|
||||||
### Adding a New Service
|
### Adding a New Service
|
||||||
1. Create interface in appropriate namespace
|
1. Create interface in appropriate namespace
|
||||||
2. Create implementation class
|
2. Create implementation class
|
||||||
3. Register in `Program.cs`:
|
3. Register in `Program.cs`:
|
||||||
```csharp
|
```csharp
|
||||||
builder.Services.AddScoped<IMyService, MyService>();
|
builder.Services.AddScoped<IMyService, MyService>();
|
||||||
```
|
```
|
||||||
|
|
||||||
### Adding a Database Migration
|
### Adding a Database Migration
|
||||||
```bash
|
```bash
|
||||||
dotnet ef migrations add MigrationName
|
dotnet ef migrations add MigrationName
|
||||||
dotnet ef database update
|
dotnet ef database update
|
||||||
```
|
```
|
||||||
|
|
||||||
### Modifying Domain Models
|
### Modifying Domain Models
|
||||||
1. Update model class in `Models/`
|
1. Update model class in `Models/`
|
||||||
2. Update `MoneyMapContext.OnModelCreating()` if needed (relationships, indexes)
|
2. Update `MoneyMapContext.OnModelCreating()` if needed (relationships, indexes)
|
||||||
3. Create and apply migration
|
3. Create and apply migration
|
||||||
|
|
||||||
## Key Design Principles
|
## Key Design Principles
|
||||||
|
|
||||||
1. **Service Layer Pattern**: Business logic lives in services, not pages
|
1. **Service Layer Pattern**: Business logic lives in services, not pages
|
||||||
2. **Result Pattern**: Services return result objects (not exceptions)
|
2. **Result Pattern**: Services return result objects (not exceptions)
|
||||||
3. **Dependency Injection**: All services injected via interfaces
|
3. **Dependency Injection**: All services injected via interfaces
|
||||||
4. **Single Responsibility**: Each service has one clear purpose
|
4. **Single Responsibility**: Each service has one clear purpose
|
||||||
5. **Clean Architecture**: UI → Services → Data Access
|
5. **Clean Architecture**: UI → Services → Data Access
|
||||||
|
|
||||||
## Important Notes
|
## Important Notes
|
||||||
|
|
||||||
- **Duplicate Prevention**: Transactions have a unique constraint on (Date, Amount, Name, Memo, AccountId, CardId)
|
- **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
|
- **Cascade Deletes**: Deleting a transaction cascades to receipts, parse logs, and line items
|
||||||
- **Merchant Assignment**: Category mappings can auto-assign merchants to transactions
|
- **Merchant Assignment**: Category mappings can auto-assign merchants to transactions
|
||||||
- **Transfer Detection**: Transactions with `TransferToAccountId` are identified as transfers
|
- **Transfer Detection**: Transactions with `TransferToAccountId` are identified as transfers
|
||||||
- **Receipt Parsing**: OpenAI API key required for receipt parsing (env var `OPENAI_API_KEY`)
|
- **Receipt Parsing**: OpenAI API key required for receipt parsing (env var `OPENAI_API_KEY`)
|
||||||
|
|
||||||
## Development Workflow
|
## Development Workflow
|
||||||
|
|
||||||
1. Read [ARCHITECTURE.md](./ARCHITECTURE.md) for technical details
|
1. Read [ARCHITECTURE.md](./ARCHITECTURE.md) for technical details
|
||||||
2. Make changes to models, services, or pages
|
2. Make changes to models, services, or pages
|
||||||
3. Test locally
|
3. Test locally
|
||||||
4. Create database migration if schema changed
|
4. Create database migration if schema changed
|
||||||
5. **Update [ARCHITECTURE.md](./ARCHITECTURE.md) if architecture changes** (new models, services, workflows, etc.)
|
5. **Update [ARCHITECTURE.md](./ARCHITECTURE.md) if architecture changes** (new models, services, workflows, etc.)
|
||||||
6. Commit with descriptive message
|
6. Commit with descriptive message
|
||||||
|
|
||||||
## Important: Keep Documentation Updated
|
## Important: Keep Documentation Updated
|
||||||
|
|
||||||
**When making architectural changes, always update [ARCHITECTURE.md](./ARCHITECTURE.md):**
|
**When making architectural changes, always update [ARCHITECTURE.md](./ARCHITECTURE.md):**
|
||||||
- Adding/removing domain models
|
- Adding/removing domain models
|
||||||
- Adding/removing services or changing their responsibilities
|
- Adding/removing services or changing their responsibilities
|
||||||
- Modifying database schema or relationships
|
- Modifying database schema or relationships
|
||||||
- Adding new workflows or processes
|
- Adding new workflows or processes
|
||||||
- Changing design patterns or conventions
|
- Changing design patterns or conventions
|
||||||
- Adding new pages or major features
|
- Adding new pages or major features
|
||||||
|
|
||||||
This ensures both Claude Code and Codex CLI have accurate, up-to-date context.
|
This ensures both Claude Code and Codex CLI have accurate, up-to-date context.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
See `appsettings.json`:
|
See `appsettings.json`:
|
||||||
- `ConnectionStrings:MoneyMapDb` - SQL Server connection
|
- `ConnectionStrings:MoneyMapDb` - SQL Server connection
|
||||||
- `OpenAI:ApiKey` - OpenAI API key (optional, use env var instead)
|
- `OpenAI:ApiKey` - OpenAI API key (optional, use env var instead)
|
||||||
- `Receipts:StoragePath` - Receipt storage location (relative to wwwroot)
|
- `Receipts:StoragePath` - Receipt storage location (relative to wwwroot)
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- All services use interfaces for mockability
|
- All services use interfaces for mockability
|
||||||
- Use in-memory database for integration tests
|
- Use in-memory database for integration tests
|
||||||
- Mock `IReceiptParser` to avoid OpenAI API calls in tests
|
- Mock `IReceiptParser` to avoid OpenAI API calls in tests
|
||||||
|
|
||||||
## Questions?
|
## Questions?
|
||||||
|
|
||||||
Refer to [ARCHITECTURE.md](./ARCHITECTURE.md) for comprehensive technical documentation including:
|
Refer to [ARCHITECTURE.md](./ARCHITECTURE.md) for comprehensive technical documentation including:
|
||||||
- Detailed service descriptions
|
- Detailed service descriptions
|
||||||
- Database schema
|
- Database schema
|
||||||
- Key workflows
|
- Key workflows
|
||||||
- Security considerations
|
- Security considerations
|
||||||
- Performance optimizations
|
- Performance optimizations
|
||||||
- Troubleshooting guide
|
- Troubleshooting guide
|
||||||
|
|||||||
@@ -1,227 +1,276 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using MoneyMap.Models;
|
using MoneyMap.Models;
|
||||||
|
|
||||||
namespace MoneyMap.Data
|
namespace MoneyMap.Data
|
||||||
{
|
{
|
||||||
public class MoneyMapContext : DbContext
|
public class MoneyMapContext : DbContext
|
||||||
{
|
{
|
||||||
public MoneyMapContext(DbContextOptions<MoneyMapContext> options) : base(options) { }
|
public MoneyMapContext(DbContextOptions<MoneyMapContext> options) : base(options) { }
|
||||||
|
|
||||||
public DbSet<Card> Cards => Set<Card>();
|
public DbSet<Card> Cards => Set<Card>();
|
||||||
public DbSet<Account> Accounts => Set<Account>();
|
public DbSet<Account> Accounts => Set<Account>();
|
||||||
public DbSet<Transaction> Transactions => Set<Transaction>();
|
public DbSet<Transaction> Transactions => Set<Transaction>();
|
||||||
public DbSet<Transfer> Transfers => Set<Transfer>();
|
public DbSet<Transfer> Transfers => Set<Transfer>();
|
||||||
public DbSet<Receipt> Receipts => Set<Receipt>();
|
public DbSet<Receipt> Receipts => Set<Receipt>();
|
||||||
public DbSet<ReceiptParseLog> ReceiptParseLogs => Set<ReceiptParseLog>();
|
public DbSet<ReceiptParseLog> ReceiptParseLogs => Set<ReceiptParseLog>();
|
||||||
public DbSet<ReceiptLineItem> ReceiptLineItems => Set<ReceiptLineItem>();
|
public DbSet<ReceiptLineItem> ReceiptLineItems => Set<ReceiptLineItem>();
|
||||||
|
|
||||||
public DbSet<CategoryMapping> CategoryMappings => Set<CategoryMapping>();
|
public DbSet<CategoryMapping> CategoryMappings => Set<CategoryMapping>();
|
||||||
public DbSet<Merchant> Merchants => Set<Merchant>();
|
public DbSet<Merchant> Merchants => Set<Merchant>();
|
||||||
public DbSet<Budget> Budgets => Set<Budget>();
|
public DbSet<Budget> Budgets => Set<Budget>();
|
||||||
|
public DbSet<RecurringPlace> RecurringPlaces => Set<RecurringPlace>();
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
public DbSet<Category> Categories => Set<Category>();
|
||||||
{
|
|
||||||
// ---------- CARD ----------
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
modelBuilder.Entity<Card>(e =>
|
{
|
||||||
{
|
// ---------- CARD ----------
|
||||||
e.Property(x => x.Issuer).HasMaxLength(100).IsRequired();
|
modelBuilder.Entity<Card>(e =>
|
||||||
e.Property(x => x.Last4).HasMaxLength(4).IsRequired();
|
{
|
||||||
e.Property(x => x.Owner).HasMaxLength(100).IsRequired();
|
e.Property(x => x.Issuer).HasMaxLength(100).IsRequired();
|
||||||
e.Property(x => x.Nickname).HasMaxLength(50);
|
e.Property(x => x.Last4).HasMaxLength(4).IsRequired();
|
||||||
|
e.Property(x => x.Owner).HasMaxLength(100).IsRequired();
|
||||||
// Card can be linked to an account (optional - for credit cards without linked account)
|
e.Property(x => x.Nickname).HasMaxLength(50);
|
||||||
e.HasOne(x => x.Account)
|
|
||||||
.WithMany(a => a.Cards)
|
// Card can be linked to an account (optional - for credit cards without linked account)
|
||||||
.HasForeignKey(x => x.AccountId)
|
e.HasOne(x => x.Account)
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
.WithMany(a => a.Cards)
|
||||||
.IsRequired(false);
|
.HasForeignKey(x => x.AccountId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
e.HasIndex(x => new { x.Issuer, x.Last4, x.Owner });
|
.IsRequired(false);
|
||||||
e.HasIndex(x => x.AccountId);
|
|
||||||
});
|
e.HasIndex(x => new { x.Issuer, x.Last4, x.Owner });
|
||||||
|
e.HasIndex(x => x.AccountId);
|
||||||
// ---------- ACCOUNT ----------
|
});
|
||||||
modelBuilder.Entity<Account>(e =>
|
|
||||||
{
|
// ---------- ACCOUNT ----------
|
||||||
e.Property(x => x.Institution).HasMaxLength(100).IsRequired();
|
modelBuilder.Entity<Account>(e =>
|
||||||
e.Property(x => x.Last4).HasMaxLength(4).IsRequired();
|
{
|
||||||
e.Property(x => x.Owner).HasMaxLength(100).IsRequired();
|
e.Property(x => x.Institution).HasMaxLength(100).IsRequired();
|
||||||
e.Property(x => x.Nickname).HasMaxLength(50);
|
e.Property(x => x.Last4).HasMaxLength(4).IsRequired();
|
||||||
e.HasIndex(x => new { x.Institution, x.Last4, x.Owner });
|
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 =>
|
|
||||||
{
|
// ---------- TRANSACTION ----------
|
||||||
e.Property(x => x.TransactionType).HasMaxLength(20);
|
modelBuilder.Entity<Transaction>(e =>
|
||||||
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
|
{
|
||||||
e.Property(x => x.Memo).HasMaxLength(500).HasDefaultValue(string.Empty);
|
e.Property(x => x.TransactionType).HasMaxLength(20);
|
||||||
e.Property(x => x.Amount).HasColumnType("decimal(18,2)");
|
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
|
||||||
e.Property(x => x.Category).HasMaxLength(100);
|
e.Property(x => x.Memo).HasMaxLength(500).HasDefaultValue(string.Empty);
|
||||||
e.Property(x => x.Last4).HasMaxLength(4);
|
e.Property(x => x.Amount).HasColumnType("decimal(18,2)");
|
||||||
|
e.Property(x => x.Category).HasMaxLength(100);
|
||||||
// Card (optional). If a card is deleted, block delete when txns exist.
|
e.Property(x => x.Last4).HasMaxLength(4);
|
||||||
e.HasOne(x => x.Card)
|
|
||||||
.WithMany(c => c.Transactions)
|
// Card (optional). If a card is deleted, block delete when txns exist.
|
||||||
.HasForeignKey(x => x.CardId)
|
e.HasOne(x => x.Card)
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
.WithMany(c => c.Transactions)
|
||||||
.IsRequired(false);
|
.HasForeignKey(x => x.CardId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
// Account (optional). If an account is deleted, block delete when txns exist.
|
.IsRequired(false);
|
||||||
e.HasOne(x => x.Account)
|
|
||||||
.WithMany(a => a.Transactions)
|
// Account (optional). If an account is deleted, block delete when txns exist.
|
||||||
.HasForeignKey(x => x.AccountId)
|
e.HasOne(x => x.Account)
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
.WithMany(a => a.Transactions)
|
||||||
.IsRequired(false);
|
.HasForeignKey(x => x.AccountId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
// Merchant (optional). If a merchant is deleted, set to null.
|
.IsRequired(false);
|
||||||
e.HasOne(x => x.Merchant)
|
|
||||||
.WithMany(m => m.Transactions)
|
// Merchant (optional). If a merchant is deleted, set to null.
|
||||||
.HasForeignKey(x => x.MerchantId)
|
e.HasOne(x => x.Merchant)
|
||||||
.OnDelete(DeleteBehavior.SetNull)
|
.WithMany(m => m.Transactions)
|
||||||
.IsRequired(false);
|
.HasForeignKey(x => x.MerchantId)
|
||||||
});
|
.OnDelete(DeleteBehavior.SetNull)
|
||||||
|
.IsRequired(false);
|
||||||
// ---------- TRANSFER ----------
|
});
|
||||||
modelBuilder.Entity<Transfer>(e =>
|
|
||||||
{
|
// ---------- TRANSFER ----------
|
||||||
e.Property(x => x.Amount).HasColumnType("decimal(18,2)");
|
modelBuilder.Entity<Transfer>(e =>
|
||||||
e.Property(x => x.Description).HasMaxLength(500);
|
{
|
||||||
|
e.Property(x => x.Amount).HasColumnType("decimal(18,2)");
|
||||||
// Source account (optional - can be "Unknown")
|
e.Property(x => x.Description).HasMaxLength(500);
|
||||||
e.HasOne(x => x.SourceAccount)
|
|
||||||
.WithMany(a => a.SourceTransfers)
|
// Source account (optional - can be "Unknown")
|
||||||
.HasForeignKey(x => x.SourceAccountId)
|
e.HasOne(x => x.SourceAccount)
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
.WithMany(a => a.SourceTransfers)
|
||||||
.IsRequired(false);
|
.HasForeignKey(x => x.SourceAccountId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
// Destination account (optional - can be "Unknown")
|
.IsRequired(false);
|
||||||
e.HasOne(x => x.DestinationAccount)
|
|
||||||
.WithMany(a => a.DestinationTransfers)
|
// Destination account (optional - can be "Unknown")
|
||||||
.HasForeignKey(x => x.DestinationAccountId)
|
e.HasOne(x => x.DestinationAccount)
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
.WithMany(a => a.DestinationTransfers)
|
||||||
.IsRequired(false);
|
.HasForeignKey(x => x.DestinationAccountId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
// Original transaction link (optional)
|
.IsRequired(false);
|
||||||
e.HasOne(x => x.OriginalTransaction)
|
|
||||||
.WithMany()
|
// Original transaction link (optional)
|
||||||
.HasForeignKey(x => x.OriginalTransactionId)
|
e.HasOne(x => x.OriginalTransaction)
|
||||||
.OnDelete(DeleteBehavior.SetNull)
|
.WithMany()
|
||||||
.IsRequired(false);
|
.HasForeignKey(x => x.OriginalTransactionId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull)
|
||||||
e.HasIndex(x => x.Date);
|
.IsRequired(false);
|
||||||
e.HasIndex(x => x.SourceAccountId);
|
|
||||||
e.HasIndex(x => x.DestinationAccountId);
|
e.HasIndex(x => x.Date);
|
||||||
});
|
e.HasIndex(x => x.SourceAccountId);
|
||||||
|
e.HasIndex(x => x.DestinationAccountId);
|
||||||
// ---------- RECEIPT ----------
|
});
|
||||||
modelBuilder.Entity<Receipt>(e =>
|
|
||||||
{
|
// ---------- RECEIPT ----------
|
||||||
e.Property(x => x.FileName).HasMaxLength(260).IsRequired();
|
modelBuilder.Entity<Receipt>(e =>
|
||||||
e.Property(x => x.ContentType).HasMaxLength(100).HasDefaultValue("application/octet-stream");
|
{
|
||||||
e.Property(x => x.StoragePath).HasMaxLength(1024).IsRequired();
|
e.Property(x => x.FileName).HasMaxLength(260).IsRequired();
|
||||||
e.Property(x => x.FileHashSha256).HasMaxLength(64).IsRequired();
|
e.Property(x => x.ContentType).HasMaxLength(100).HasDefaultValue("application/octet-stream");
|
||||||
|
e.Property(x => x.StoragePath).HasMaxLength(1024).IsRequired();
|
||||||
e.Property(x => x.Merchant).HasMaxLength(200);
|
e.Property(x => x.FileHashSha256).HasMaxLength(64).IsRequired();
|
||||||
e.Property(x => x.Subtotal).HasColumnType("decimal(18,2)");
|
|
||||||
e.Property(x => x.Tax).HasColumnType("decimal(18,2)");
|
e.Property(x => x.Merchant).HasMaxLength(200);
|
||||||
e.Property(x => x.Total).HasColumnType("decimal(18,2)");
|
e.Property(x => x.Subtotal).HasColumnType("decimal(18,2)");
|
||||||
e.Property(x => x.Currency).HasMaxLength(8);
|
e.Property(x => x.Tax).HasColumnType("decimal(18,2)");
|
||||||
|
e.Property(x => x.Total).HasColumnType("decimal(18,2)");
|
||||||
e.Property(x => x.ParseStatus).HasDefaultValue(ReceiptParseStatus.NotRequested);
|
e.Property(x => x.Currency).HasMaxLength(8);
|
||||||
e.HasIndex(x => x.ParseStatus);
|
|
||||||
|
e.Property(x => x.ParseStatus).HasDefaultValue(ReceiptParseStatus.NotRequested);
|
||||||
// Receipt can optionally belong to a Transaction. If txn is deleted, cascade remove receipts.
|
e.HasIndex(x => x.ParseStatus);
|
||||||
e.HasOne(x => x.Transaction)
|
|
||||||
.WithMany(t => t.Receipts)
|
// Receipt can optionally belong to a Transaction. If txn is deleted, cascade remove receipts.
|
||||||
.HasForeignKey(x => x.TransactionId)
|
e.HasOne(x => x.Transaction)
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.WithMany(t => t.Receipts)
|
||||||
.IsRequired(false);
|
.HasForeignKey(x => x.TransactionId)
|
||||||
});
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired(false);
|
||||||
// ---------- RECEIPT PARSE LOG ----------
|
});
|
||||||
modelBuilder.Entity<ReceiptParseLog>(e =>
|
|
||||||
{
|
// ---------- RECEIPT PARSE LOG ----------
|
||||||
e.Property(x => x.Provider).HasMaxLength(50).IsRequired();
|
modelBuilder.Entity<ReceiptParseLog>(e =>
|
||||||
e.Property(x => x.Model).HasMaxLength(100).IsRequired();
|
{
|
||||||
e.Property(x => x.ProviderJobId).HasMaxLength(100);
|
e.Property(x => x.Provider).HasMaxLength(50).IsRequired();
|
||||||
e.Property(x => x.ExtractedTextPath).HasMaxLength(1024);
|
e.Property(x => x.Model).HasMaxLength(100).IsRequired();
|
||||||
|
e.Property(x => x.ProviderJobId).HasMaxLength(100);
|
||||||
e.HasOne(x => x.Receipt)
|
e.Property(x => x.ExtractedTextPath).HasMaxLength(1024);
|
||||||
.WithMany(r => r.ParseLogs)
|
|
||||||
.HasForeignKey(x => x.ReceiptId)
|
e.HasOne(x => x.Receipt)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.WithMany(r => r.ParseLogs)
|
||||||
});
|
.HasForeignKey(x => x.ReceiptId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
// ---------- RECEIPT LINE ITEM ----------
|
});
|
||||||
modelBuilder.Entity<ReceiptLineItem>(e =>
|
|
||||||
{
|
// ---------- RECEIPT LINE ITEM ----------
|
||||||
e.Property(x => x.Description).HasMaxLength(300).IsRequired();
|
modelBuilder.Entity<ReceiptLineItem>(e =>
|
||||||
e.Property(x => x.Unit).HasMaxLength(16);
|
{
|
||||||
e.Property(x => x.UnitPrice).HasColumnType("decimal(18,4)");
|
e.Property(x => x.Description).HasMaxLength(300).IsRequired();
|
||||||
e.Property(x => x.LineTotal).HasColumnType("decimal(18,2)");
|
e.Property(x => x.Unit).HasMaxLength(16);
|
||||||
e.Property(x => x.Sku).HasMaxLength(64);
|
e.Property(x => x.UnitPrice).HasColumnType("decimal(18,4)");
|
||||||
e.Property(x => x.Category).HasMaxLength(100);
|
e.Property(x => x.LineTotal).HasColumnType("decimal(18,2)");
|
||||||
|
e.Property(x => x.Sku).HasMaxLength(64);
|
||||||
e.HasOne(x => x.Receipt)
|
e.Property(x => x.Category).HasMaxLength(100);
|
||||||
.WithMany(r => r.LineItems)
|
|
||||||
.HasForeignKey(x => x.ReceiptId)
|
e.HasOne(x => x.Receipt)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.WithMany(r => r.LineItems)
|
||||||
});
|
.HasForeignKey(x => x.ReceiptId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
// ---------- MERCHANT ----------
|
});
|
||||||
modelBuilder.Entity<Merchant>(e =>
|
|
||||||
{
|
// ---------- MERCHANT ----------
|
||||||
e.Property(x => x.Name).HasMaxLength(100).IsRequired();
|
modelBuilder.Entity<Merchant>(e =>
|
||||||
e.HasIndex(x => x.Name).IsUnique();
|
{
|
||||||
});
|
e.Property(x => x.Name).HasMaxLength(100).IsRequired();
|
||||||
|
e.HasIndex(x => x.Name).IsUnique();
|
||||||
// ---------- CATEGORY MAPPING ----------
|
});
|
||||||
modelBuilder.Entity<CategoryMapping>(e =>
|
|
||||||
{
|
// ---------- CATEGORY MAPPING ----------
|
||||||
e.Property(x => x.Category).HasMaxLength(100).IsRequired();
|
modelBuilder.Entity<CategoryMapping>(e =>
|
||||||
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.Category).HasMaxLength(100).IsRequired();
|
||||||
e.Property(x => x.CreatedBy).HasMaxLength(50);
|
e.Property(x => x.Pattern).HasMaxLength(200).IsRequired();
|
||||||
|
e.Property(x => x.Confidence).HasColumnType("decimal(5,4)"); // 0.0000 to 1.0000
|
||||||
// Merchant (optional). If a merchant is deleted, set to null.
|
e.Property(x => x.CreatedBy).HasMaxLength(50);
|
||||||
e.HasOne(x => x.Merchant)
|
|
||||||
.WithMany(m => m.CategoryMappings)
|
// Merchant (optional). If a merchant is deleted, set to null.
|
||||||
.HasForeignKey(x => x.MerchantId)
|
e.HasOne(x => x.Merchant)
|
||||||
.OnDelete(DeleteBehavior.SetNull)
|
.WithMany(m => m.CategoryMappings)
|
||||||
.IsRequired(false);
|
.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);
|
// ---------- Extra SQL Server–friendly indexes ----------
|
||||||
modelBuilder.Entity<Transaction>().HasIndex(x => x.Amount);
|
// Fast filtering by date/amount/category
|
||||||
modelBuilder.Entity<Transaction>().HasIndex(x => x.Category);
|
modelBuilder.Entity<Transaction>().HasIndex(x => x.Date);
|
||||||
modelBuilder.Entity<Transaction>().HasIndex(x => x.MerchantId);
|
modelBuilder.Entity<Transaction>().HasIndex(x => x.Amount);
|
||||||
|
modelBuilder.Entity<Transaction>().HasIndex(x => x.Category);
|
||||||
// Composite indexes for common query patterns
|
modelBuilder.Entity<Transaction>().HasIndex(x => x.MerchantId);
|
||||||
modelBuilder.Entity<Transaction>().HasIndex(x => new { x.AccountId, x.Category });
|
|
||||||
modelBuilder.Entity<Transaction>().HasIndex(x => new { x.AccountId, x.Date });
|
// Composite indexes for common query patterns
|
||||||
modelBuilder.Entity<Transaction>().HasIndex(x => new { x.MerchantId, x.Date });
|
modelBuilder.Entity<Transaction>().HasIndex(x => new { x.AccountId, x.Category });
|
||||||
modelBuilder.Entity<Transaction>().HasIndex(x => new { x.CardId, x.Date });
|
modelBuilder.Entity<Transaction>().HasIndex(x => new { x.AccountId, x.Date });
|
||||||
|
modelBuilder.Entity<Transaction>().HasIndex(x => new { x.MerchantId, x.Date });
|
||||||
// Receipt duplicate detection and lookup
|
modelBuilder.Entity<Transaction>().HasIndex(x => new { x.CardId, x.Date });
|
||||||
modelBuilder.Entity<Receipt>().HasIndex(x => x.FileHashSha256);
|
|
||||||
modelBuilder.Entity<Receipt>().HasIndex(x => new { x.TransactionId, x.ReceiptDate });
|
// Receipt duplicate detection and lookup
|
||||||
|
modelBuilder.Entity<Receipt>().HasIndex(x => x.FileHashSha256);
|
||||||
// ---------- BUDGET ----------
|
modelBuilder.Entity<Receipt>().HasIndex(x => new { x.TransactionId, x.ReceiptDate });
|
||||||
modelBuilder.Entity<Budget>(e =>
|
|
||||||
{
|
// ---------- BUDGET ----------
|
||||||
e.Property(x => x.Category).HasMaxLength(100);
|
modelBuilder.Entity<Budget>(e =>
|
||||||
e.Property(x => x.Amount).HasColumnType("decimal(18,2)");
|
{
|
||||||
e.Property(x => x.Notes).HasMaxLength(500);
|
e.Property(x => x.Category).HasMaxLength(100);
|
||||||
|
e.Property(x => x.Amount).HasColumnType("decimal(18,2)");
|
||||||
// Only one active budget per category per period
|
e.Property(x => x.Notes).HasMaxLength(500);
|
||||||
// Null category = total budget, so we use a filtered unique index
|
|
||||||
e.HasIndex(x => new { x.Category, x.Period })
|
// Only one active budget per category per period
|
||||||
.HasFilter("[IsActive] = 1")
|
// Null category = total budget, so we use a filtered unique index
|
||||||
.IsUnique();
|
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.Configuration;
|
||||||
global using Microsoft.Extensions.DependencyInjection;
|
global using Microsoft.Extensions.DependencyInjection;
|
||||||
global using Microsoft.Extensions.Logging;
|
global using Microsoft.Extensions.Logging;
|
||||||
global using Microsoft.AspNetCore.Hosting;
|
global using Microsoft.AspNetCore.Hosting;
|
||||||
global using Microsoft.AspNetCore.Http;
|
global using Microsoft.AspNetCore.Http;
|
||||||
|
|||||||
@@ -1,45 +1,45 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
namespace MoneyMap.Models;
|
namespace MoneyMap.Models;
|
||||||
|
|
||||||
public enum AccountType
|
public enum AccountType
|
||||||
{
|
{
|
||||||
Checking,
|
Checking,
|
||||||
Savings,
|
Savings,
|
||||||
Other
|
Other
|
||||||
}
|
}
|
||||||
|
|
||||||
public class Account
|
public class Account
|
||||||
{
|
{
|
||||||
[Key]
|
[Key]
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
[MaxLength(100)]
|
[MaxLength(100)]
|
||||||
public string Institution { get; set; } = string.Empty; // e.g., "Chase", "Wells Fargo"
|
public string Institution { get; set; } = string.Empty; // e.g., "Chase", "Wells Fargo"
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
[MaxLength(4)]
|
[MaxLength(4)]
|
||||||
public string Last4 { get; set; } = string.Empty; // Last 4 digits of account number
|
public string Last4 { get; set; } = string.Empty; // Last 4 digits of account number
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
[MaxLength(100)]
|
[MaxLength(100)]
|
||||||
public string Owner { get; set; } = string.Empty; // Account holder name
|
public string Owner { get; set; } = string.Empty; // Account holder name
|
||||||
|
|
||||||
public AccountType AccountType { get; set; } = AccountType.Checking;
|
public AccountType AccountType { get; set; } = AccountType.Checking;
|
||||||
|
|
||||||
[MaxLength(50)]
|
[MaxLength(50)]
|
||||||
public string? Nickname { get; set; } // Optional friendly name like "Emergency Fund"
|
public string? Nickname { get; set; } // Optional friendly name like "Emergency Fund"
|
||||||
|
|
||||||
// Navigation properties
|
// Navigation properties
|
||||||
public ICollection<Card> Cards { get; set; } = new List<Card>(); // Cards linked to this account
|
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<Transaction> Transactions { get; set; } = new List<Transaction>();
|
||||||
public ICollection<Transfer> SourceTransfers { get; set; } = new List<Transfer>();
|
public ICollection<Transfer> SourceTransfers { get; set; } = new List<Transfer>();
|
||||||
public ICollection<Transfer> DestinationTransfers { get; set; } = new List<Transfer>();
|
public ICollection<Transfer> DestinationTransfers { get; set; } = new List<Transfer>();
|
||||||
|
|
||||||
[NotMapped]
|
[NotMapped]
|
||||||
public string DisplayLabel => string.IsNullOrEmpty(Nickname)
|
public string DisplayLabel => string.IsNullOrEmpty(Nickname)
|
||||||
? $"{Institution} {Last4} ({AccountType})"
|
? $"{Institution} {Last4} ({AccountType})"
|
||||||
: $"{Nickname} ({Institution} {Last4})";
|
: $"{Nickname} ({Institution} {Last4})";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,137 +1,137 @@
|
|||||||
namespace MoneyMap.Models.Api;
|
namespace MoneyMap.Models.Api;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Complete financial audit response for AI analysis.
|
/// Complete financial audit response for AI analysis.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class FinancialAuditResponse
|
public class FinancialAuditResponse
|
||||||
{
|
{
|
||||||
public DateTime GeneratedAt { get; set; }
|
public DateTime GeneratedAt { get; set; }
|
||||||
public DateTime PeriodStart { get; set; }
|
public DateTime PeriodStart { get; set; }
|
||||||
public DateTime PeriodEnd { get; set; }
|
public DateTime PeriodEnd { get; set; }
|
||||||
|
|
||||||
public AuditSummary Summary { get; set; } = new();
|
public AuditSummary Summary { get; set; } = new();
|
||||||
public List<BudgetStatusDto> Budgets { get; set; } = new();
|
public List<BudgetStatusDto> Budgets { get; set; } = new();
|
||||||
public List<CategorySpendingDto> SpendingByCategory { get; set; } = new();
|
public List<CategorySpendingDto> SpendingByCategory { get; set; } = new();
|
||||||
public List<MerchantSpendingDto> TopMerchants { get; set; } = new();
|
public List<MerchantSpendingDto> TopMerchants { get; set; } = new();
|
||||||
public List<MonthlyTrendDto> MonthlyTrends { get; set; } = new();
|
public List<MonthlyTrendDto> MonthlyTrends { get; set; } = new();
|
||||||
public List<AccountSummaryDto> Accounts { get; set; } = new();
|
public List<AccountSummaryDto> Accounts { get; set; } = new();
|
||||||
public List<AuditFlagDto> Flags { get; set; } = new();
|
public List<AuditFlagDto> Flags { get; set; } = new();
|
||||||
public List<TransactionDto>? Transactions { get; set; }
|
public List<TransactionDto>? Transactions { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// High-level financial statistics for the audit period.
|
/// High-level financial statistics for the audit period.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AuditSummary
|
public class AuditSummary
|
||||||
{
|
{
|
||||||
public int TotalTransactions { get; set; }
|
public int TotalTransactions { get; set; }
|
||||||
public decimal TotalIncome { get; set; }
|
public decimal TotalIncome { get; set; }
|
||||||
public decimal TotalExpenses { get; set; }
|
public decimal TotalExpenses { get; set; }
|
||||||
public decimal NetCashFlow { get; set; }
|
public decimal NetCashFlow { get; set; }
|
||||||
public decimal AverageDailySpend { get; set; }
|
public decimal AverageDailySpend { get; set; }
|
||||||
public int DaysInPeriod { get; set; }
|
public int DaysInPeriod { get; set; }
|
||||||
public int UncategorizedTransactions { get; set; }
|
public int UncategorizedTransactions { get; set; }
|
||||||
public decimal UncategorizedAmount { get; set; }
|
public decimal UncategorizedAmount { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Budget status with period information.
|
/// Budget status with period information.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class BudgetStatusDto
|
public class BudgetStatusDto
|
||||||
{
|
{
|
||||||
public int BudgetId { get; set; }
|
public int BudgetId { get; set; }
|
||||||
public string Category { get; set; } = "";
|
public string Category { get; set; } = "";
|
||||||
public string Period { get; set; } = "";
|
public string Period { get; set; } = "";
|
||||||
public decimal Limit { get; set; }
|
public decimal Limit { get; set; }
|
||||||
public decimal Spent { get; set; }
|
public decimal Spent { get; set; }
|
||||||
public decimal Remaining { get; set; }
|
public decimal Remaining { get; set; }
|
||||||
public decimal PercentUsed { get; set; }
|
public decimal PercentUsed { get; set; }
|
||||||
public bool IsOverBudget { get; set; }
|
public bool IsOverBudget { get; set; }
|
||||||
public string PeriodRange { get; set; } = "";
|
public string PeriodRange { get; set; } = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Spending breakdown by category with optional budget correlation.
|
/// Spending breakdown by category with optional budget correlation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class CategorySpendingDto
|
public class CategorySpendingDto
|
||||||
{
|
{
|
||||||
public string Category { get; set; } = "";
|
public string Category { get; set; } = "";
|
||||||
public decimal TotalSpent { get; set; }
|
public decimal TotalSpent { get; set; }
|
||||||
public int TransactionCount { get; set; }
|
public int TransactionCount { get; set; }
|
||||||
public decimal PercentOfTotal { get; set; }
|
public decimal PercentOfTotal { get; set; }
|
||||||
public decimal AverageTransaction { get; set; }
|
public decimal AverageTransaction { get; set; }
|
||||||
public decimal? BudgetLimit { get; set; }
|
public decimal? BudgetLimit { get; set; }
|
||||||
public decimal? BudgetRemaining { get; set; }
|
public decimal? BudgetRemaining { get; set; }
|
||||||
public bool? IsOverBudget { get; set; }
|
public bool? IsOverBudget { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Spending patterns by merchant.
|
/// Spending patterns by merchant.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class MerchantSpendingDto
|
public class MerchantSpendingDto
|
||||||
{
|
{
|
||||||
public string MerchantName { get; set; } = "";
|
public string MerchantName { get; set; } = "";
|
||||||
public string? Category { get; set; }
|
public string? Category { get; set; }
|
||||||
public decimal TotalSpent { get; set; }
|
public decimal TotalSpent { get; set; }
|
||||||
public int TransactionCount { get; set; }
|
public int TransactionCount { get; set; }
|
||||||
public decimal AverageTransaction { get; set; }
|
public decimal AverageTransaction { get; set; }
|
||||||
public DateTime FirstTransaction { get; set; }
|
public DateTime FirstTransaction { get; set; }
|
||||||
public DateTime LastTransaction { get; set; }
|
public DateTime LastTransaction { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Monthly income/expense/net trends.
|
/// Monthly income/expense/net trends.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class MonthlyTrendDto
|
public class MonthlyTrendDto
|
||||||
{
|
{
|
||||||
public string Month { get; set; } = "";
|
public string Month { get; set; } = "";
|
||||||
public int Year { get; set; }
|
public int Year { get; set; }
|
||||||
public decimal Income { get; set; }
|
public decimal Income { get; set; }
|
||||||
public decimal Expenses { get; set; }
|
public decimal Expenses { get; set; }
|
||||||
public decimal NetCashFlow { get; set; }
|
public decimal NetCashFlow { get; set; }
|
||||||
public int TransactionCount { get; set; }
|
public int TransactionCount { get; set; }
|
||||||
public Dictionary<string, decimal> TopCategories { get; set; } = new();
|
public Dictionary<string, decimal> TopCategories { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per-account transaction summary.
|
/// Per-account transaction summary.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AccountSummaryDto
|
public class AccountSummaryDto
|
||||||
{
|
{
|
||||||
public int AccountId { get; set; }
|
public int AccountId { get; set; }
|
||||||
public string AccountName { get; set; } = "";
|
public string AccountName { get; set; } = "";
|
||||||
public string Institution { get; set; } = "";
|
public string Institution { get; set; } = "";
|
||||||
public string AccountType { get; set; } = "";
|
public string AccountType { get; set; } = "";
|
||||||
public int TransactionCount { get; set; }
|
public int TransactionCount { get; set; }
|
||||||
public decimal TotalDebits { get; set; }
|
public decimal TotalDebits { get; set; }
|
||||||
public decimal TotalCredits { get; set; }
|
public decimal TotalCredits { get; set; }
|
||||||
public decimal NetFlow { get; set; }
|
public decimal NetFlow { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// AI-friendly flag highlighting potential issues or observations.
|
/// AI-friendly flag highlighting potential issues or observations.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AuditFlagDto
|
public class AuditFlagDto
|
||||||
{
|
{
|
||||||
public string Type { get; set; } = "";
|
public string Type { get; set; } = "";
|
||||||
public string Severity { get; set; } = "";
|
public string Severity { get; set; } = "";
|
||||||
public string Message { get; set; } = "";
|
public string Message { get; set; } = "";
|
||||||
public object? Details { get; set; }
|
public object? Details { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Simplified transaction for export.
|
/// Simplified transaction for export.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class TransactionDto
|
public class TransactionDto
|
||||||
{
|
{
|
||||||
public long Id { get; set; }
|
public long Id { get; set; }
|
||||||
public DateTime Date { get; set; }
|
public DateTime Date { get; set; }
|
||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
public string? Memo { get; set; }
|
public string? Memo { get; set; }
|
||||||
public decimal Amount { get; set; }
|
public decimal Amount { get; set; }
|
||||||
public string? Category { get; set; }
|
public string? Category { get; set; }
|
||||||
public string? MerchantName { get; set; }
|
public string? MerchantName { get; set; }
|
||||||
public string AccountName { get; set; } = "";
|
public string AccountName { get; set; } = "";
|
||||||
public string? CardLabel { get; set; }
|
public string? CardLabel { get; set; }
|
||||||
public bool IsTransfer { get; set; }
|
public bool IsTransfer { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +1,62 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
namespace MoneyMap.Models;
|
namespace MoneyMap.Models;
|
||||||
|
|
||||||
public enum BudgetPeriod
|
public enum BudgetPeriod
|
||||||
{
|
{
|
||||||
Weekly = 0,
|
Weekly = 0,
|
||||||
Monthly = 1,
|
Monthly = 1,
|
||||||
Yearly = 2
|
Yearly = 2
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents a spending budget for a category or total spending.
|
/// Represents a spending budget for a category or total spending.
|
||||||
/// When Category is null, this is a "Total" budget that tracks all spending.
|
/// When Category is null, this is a "Total" budget that tracks all spending.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Budget
|
public class Budget
|
||||||
{
|
{
|
||||||
[Key]
|
[Key]
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The category this budget applies to.
|
/// The category this budget applies to.
|
||||||
/// Null means this is a total spending budget (all categories combined).
|
/// Null means this is a total spending budget (all categories combined).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[MaxLength(100)]
|
[MaxLength(100)]
|
||||||
public string? Category { get; set; }
|
public string? Category { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The budget limit amount (positive value).
|
/// The budget limit amount (positive value).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Column(TypeName = "decimal(18,2)")]
|
[Column(TypeName = "decimal(18,2)")]
|
||||||
public decimal Amount { get; set; }
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The time period for this budget (Weekly, Monthly, Yearly).
|
/// The time period for this budget (Weekly, Monthly, Yearly).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public BudgetPeriod Period { get; set; } = BudgetPeriod.Monthly;
|
public BudgetPeriod Period { get; set; } = BudgetPeriod.Monthly;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// When the budget becomes effective. Used to calculate period boundaries.
|
/// When the budget becomes effective. Used to calculate period boundaries.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime StartDate { get; set; } = DateTime.Today;
|
public DateTime StartDate { get; set; } = DateTime.Today;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether this budget is currently active.
|
/// Whether this budget is currently active.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Optional notes about this budget.
|
/// Optional notes about this budget.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[MaxLength(500)]
|
[MaxLength(500)]
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
[NotMapped]
|
[NotMapped]
|
||||||
public bool IsTotalBudget => Category == null;
|
public bool IsTotalBudget => Category == null;
|
||||||
|
|
||||||
[NotMapped]
|
[NotMapped]
|
||||||
public string DisplayName => Category ?? "Total Spending";
|
public string DisplayName => Category ?? "Total Spending";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,38 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
namespace MoneyMap.Models;
|
namespace MoneyMap.Models;
|
||||||
|
|
||||||
public class Card
|
public class Card
|
||||||
{
|
{
|
||||||
[Key]
|
[Key]
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
[MaxLength(100)]
|
[MaxLength(100)]
|
||||||
public string Issuer { get; set; } = string.Empty; // e.g., VISA, MC, Discover
|
public string Issuer { get; set; } = string.Empty; // e.g., VISA, MC, Discover
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
[MaxLength(4)]
|
[MaxLength(4)]
|
||||||
public string Last4 { get; set; } = string.Empty; // "1234"
|
public string Last4 { get; set; } = string.Empty; // "1234"
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
[MaxLength(100)]
|
[MaxLength(100)]
|
||||||
public string Owner { get; set; } = string.Empty;
|
public string Owner { get; set; } = string.Empty;
|
||||||
|
|
||||||
// Link to the account this card draws from/pays to
|
// Link to the account this card draws from/pays to
|
||||||
[ForeignKey(nameof(Account))]
|
[ForeignKey(nameof(Account))]
|
||||||
public int? AccountId { get; set; }
|
public int? AccountId { get; set; }
|
||||||
public Account? Account { get; set; }
|
public Account? Account { get; set; }
|
||||||
|
|
||||||
[MaxLength(50)]
|
[MaxLength(50)]
|
||||||
public string? Nickname { get; set; } // Optional friendly name
|
public string? Nickname { get; set; } // Optional friendly name
|
||||||
|
|
||||||
public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>();
|
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)
|
[NotMapped]
|
||||||
? $"{Issuer} {Last4}"
|
public string DisplayLabel => string.IsNullOrEmpty(Nickname)
|
||||||
: $"{Nickname} ({Issuer} {Last4})";
|
? $"{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
|
namespace MoneyMap.Models
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents a mapping rule that associates transaction name patterns with categories.
|
/// Represents a mapping rule that associates transaction name patterns with categories.
|
||||||
/// Used for automatic categorization of transactions during import.
|
/// Used for automatic categorization of transactions during import.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class CategoryMapping
|
public class CategoryMapping
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The category to assign when a transaction matches the pattern.
|
/// The category to assign when a transaction matches the pattern.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public required string Category { get; set; }
|
public required string Category { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The pattern to match against transaction names (case-insensitive contains).
|
/// The pattern to match against transaction names (case-insensitive contains).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public required string Pattern { get; set; }
|
public required string Pattern { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Higher priority mappings are checked first. Default is 0.
|
/// Higher priority mappings are checked first. Default is 0.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int Priority { get; set; } = 0;
|
public int Priority { get; set; } = 0;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Optional merchant to auto-assign when this pattern matches.
|
/// Optional merchant to auto-assign when this pattern matches.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? MerchantId { get; set; }
|
public int? MerchantId { get; set; }
|
||||||
public Merchant? Merchant { get; set; }
|
public Merchant? Merchant { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// AI confidence score when this rule was created by AI (0.0 - 1.0).
|
/// AI confidence score when this rule was created by AI (0.0 - 1.0).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public decimal? Confidence { get; set; }
|
public decimal? Confidence { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Who created this rule: "User" or "AI".
|
/// Who created this rule: "User" or "AI".
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? CreatedBy { get; set; }
|
public string? CreatedBy { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// When this rule was created.
|
/// When this rule was created.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime? CreatedAt { get; set; }
|
public DateTime? CreatedAt { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +1,63 @@
|
|||||||
namespace MoneyMap.Models.Dashboard
|
namespace MoneyMap.Models.Dashboard
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Statistics displayed on the dashboard.
|
/// Statistics displayed on the dashboard.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record DashboardStats(
|
public record DashboardStats(
|
||||||
int TotalTransactions = 0,
|
int TotalTransactions = 0,
|
||||||
int Credits = 0,
|
int Credits = 0,
|
||||||
int Debits = 0,
|
int Debits = 0,
|
||||||
int Uncategorized = 0,
|
int Uncategorized = 0,
|
||||||
int Receipts = 0,
|
int Receipts = 0,
|
||||||
int Cards = 0);
|
int Cards = 0);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Row representing spending in a category.
|
/// Row representing spending in a category.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class TopCategoryRow
|
public class TopCategoryRow
|
||||||
{
|
{
|
||||||
public string Category { get; set; } = "";
|
public string Category { get; set; } = "";
|
||||||
public decimal TotalSpend { get; set; }
|
public decimal TotalSpend { get; set; }
|
||||||
public int Count { get; set; }
|
public int Count { get; set; }
|
||||||
public decimal PercentageOfTotal { get; set; }
|
public decimal PercentageOfTotal { get; set; }
|
||||||
public decimal AveragePerTransaction { get; set; }
|
public decimal AveragePerTransaction { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Row representing a recent transaction.
|
/// Row representing a recent transaction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class RecentTransactionRow
|
public class RecentTransactionRow
|
||||||
{
|
{
|
||||||
public long Id { get; set; }
|
public long Id { get; set; }
|
||||||
public DateTime Date { get; set; }
|
public DateTime Date { get; set; }
|
||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
public string Memo { get; set; } = "";
|
public string Memo { get; set; } = "";
|
||||||
public decimal Amount { get; set; }
|
public decimal Amount { get; set; }
|
||||||
public string Category { get; set; } = "";
|
public string Category { get; set; } = "";
|
||||||
public string CardLabel { get; set; } = "";
|
public string CardLabel { get; set; } = "";
|
||||||
public int ReceiptCount { get; set; }
|
public int ReceiptCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Spending trends over time.
|
/// Spending trends over time.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SpendTrends
|
public class SpendTrends
|
||||||
{
|
{
|
||||||
public List<string> Labels { get; set; } = new();
|
public List<string> Labels { get; set; } = new();
|
||||||
public List<decimal> DebitsAbs { get; set; } = new();
|
public List<decimal> DebitsAbs { get; set; } = new();
|
||||||
public List<decimal> Credits { get; set; } = new();
|
public List<decimal> Credits { get; set; } = new();
|
||||||
public List<decimal> Net { get; set; } = new();
|
public List<decimal> Net { get; set; } = new();
|
||||||
public List<decimal> RunningBalance { get; set; } = new();
|
public List<decimal> RunningBalance { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Complete dashboard data package.
|
/// Complete dashboard data package.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class DashboardData
|
public class DashboardData
|
||||||
{
|
{
|
||||||
public required DashboardStats Stats { get; init; }
|
public required DashboardStats Stats { get; init; }
|
||||||
public required List<TopCategoryRow> TopCategories { get; init; }
|
public required List<TopCategoryRow> TopCategories { get; init; }
|
||||||
public required List<RecentTransactionRow> RecentTransactions { get; init; }
|
public required List<RecentTransactionRow> RecentTransactions { get; init; }
|
||||||
public required SpendTrends Trends { get; init; }
|
public required SpendTrends Trends { get; init; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
namespace MoneyMap.Models.Import
|
namespace MoneyMap.Models.Import
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Context for transaction import operations, containing payment selection mode and available options.
|
/// Context for transaction import operations, containing payment selection mode and available options.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ImportContext
|
public class ImportContext
|
||||||
{
|
{
|
||||||
public required PaymentSelectMode PaymentMode { get; init; }
|
public required PaymentSelectMode PaymentMode { get; init; }
|
||||||
public int? SelectedCardId { get; init; }
|
public int? SelectedCardId { get; init; }
|
||||||
public int? SelectedAccountId { get; init; }
|
public int? SelectedAccountId { get; init; }
|
||||||
public required List<Card> AvailableCards { get; init; }
|
public required List<Card> AvailableCards { get; init; }
|
||||||
public required List<Account> AvailableAccounts { get; init; }
|
public required List<Account> AvailableAccounts { get; init; }
|
||||||
public required string FileName { get; init; }
|
public required string FileName { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Specifies how to determine the payment method for imported transactions.
|
/// Specifies how to determine the payment method for imported transactions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum PaymentSelectMode
|
public enum PaymentSelectMode
|
||||||
{
|
{
|
||||||
/// <summary>Auto-detect from memo or filename.</summary>
|
/// <summary>Auto-detect from memo or filename.</summary>
|
||||||
Auto,
|
Auto,
|
||||||
/// <summary>Use a specific card for all transactions.</summary>
|
/// <summary>Use a specific card for all transactions.</summary>
|
||||||
Card,
|
Card,
|
||||||
/// <summary>Use a specific account for all transactions.</summary>
|
/// <summary>Use a specific account for all transactions.</summary>
|
||||||
Account
|
Account
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +1,69 @@
|
|||||||
namespace MoneyMap.Models.Import
|
namespace MoneyMap.Models.Import
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Result of an import operation, showing counts of processed transactions.
|
/// Result of an import operation, showing counts of processed transactions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record ImportResult(int Total, int Inserted, int Skipped, string? Last4FromFile);
|
public record ImportResult(int Total, int Inserted, int Skipped, string? Last4FromFile);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wrapper for import operation result with success/failure state.
|
/// Wrapper for import operation result with success/failure state.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ImportOperationResult
|
public class ImportOperationResult
|
||||||
{
|
{
|
||||||
public bool IsSuccess { get; init; }
|
public bool IsSuccess { get; init; }
|
||||||
public ImportResult? Data { get; init; }
|
public ImportResult? Data { get; init; }
|
||||||
public string? ErrorMessage { get; init; }
|
public string? ErrorMessage { get; init; }
|
||||||
|
|
||||||
public static ImportOperationResult Success(ImportResult data) =>
|
public static ImportOperationResult Success(ImportResult data) =>
|
||||||
new() { IsSuccess = true, Data = data };
|
new() { IsSuccess = true, Data = data };
|
||||||
|
|
||||||
public static ImportOperationResult Failure(string error) =>
|
public static ImportOperationResult Failure(string error) =>
|
||||||
new() { IsSuccess = false, ErrorMessage = error };
|
new() { IsSuccess = false, ErrorMessage = error };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wrapper for preview operation result with success/failure state.
|
/// Wrapper for preview operation result with success/failure state.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class PreviewOperationResult
|
public class PreviewOperationResult
|
||||||
{
|
{
|
||||||
public bool IsSuccess { get; init; }
|
public bool IsSuccess { get; init; }
|
||||||
public List<TransactionPreview>? Data { get; init; }
|
public List<TransactionPreview>? Data { get; init; }
|
||||||
public string? ErrorMessage { get; init; }
|
public string? ErrorMessage { get; init; }
|
||||||
|
|
||||||
public static PreviewOperationResult Success(List<TransactionPreview> data) =>
|
public static PreviewOperationResult Success(List<TransactionPreview> data) =>
|
||||||
new() { IsSuccess = true, Data = data };
|
new() { IsSuccess = true, Data = data };
|
||||||
|
|
||||||
public static PreviewOperationResult Failure(string error) =>
|
public static PreviewOperationResult Failure(string error) =>
|
||||||
new() { IsSuccess = false, ErrorMessage = error };
|
new() { IsSuccess = false, ErrorMessage = error };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Preview of a transaction before import, with duplicate detection info.
|
/// Preview of a transaction before import, with duplicate detection info.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class TransactionPreview
|
public class TransactionPreview
|
||||||
{
|
{
|
||||||
public required Transaction Transaction { get; init; }
|
public required Transaction Transaction { get; init; }
|
||||||
public bool IsDuplicate { get; init; }
|
public bool IsDuplicate { get; init; }
|
||||||
public required string PaymentMethodLabel { get; init; }
|
public required string PaymentMethodLabel { get; init; }
|
||||||
public string? SuggestedCategory { get; set; }
|
public string? SuggestedCategory { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// User's selection for payment method during import confirmation.
|
/// User's selection for payment method during import confirmation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class PaymentSelection
|
public class PaymentSelection
|
||||||
{
|
{
|
||||||
public int? AccountId { get; set; }
|
public int? AccountId { get; set; }
|
||||||
public int? CardId { get; set; }
|
public int? CardId { get; set; }
|
||||||
public string? Category { get; set; }
|
public string? Category { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Key for detecting duplicate transactions.
|
/// Key for detecting duplicate transactions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record TransactionKey(DateTime Date, decimal Amount, string Name, string Memo, int AccountId, int? CardId)
|
public record TransactionKey(DateTime Date, decimal Amount, string Name, string Memo, int AccountId, int? CardId)
|
||||||
{
|
{
|
||||||
public TransactionKey(Transaction txn)
|
public TransactionKey(Transaction txn)
|
||||||
: this(txn.Date, txn.Amount, txn.Name, txn.Memo, txn.AccountId, txn.CardId) { }
|
: this(txn.Date, txn.Amount, txn.Name, txn.Memo, txn.AccountId, txn.CardId) { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
namespace MoneyMap.Models.Import
|
namespace MoneyMap.Models.Import
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Result of resolving a payment method (card or account) for a transaction.
|
/// Result of resolving a payment method (card or account) for a transaction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class PaymentResolutionResult
|
public class PaymentResolutionResult
|
||||||
{
|
{
|
||||||
public bool IsSuccess { get; init; }
|
public bool IsSuccess { get; init; }
|
||||||
public int? CardId { get; init; }
|
public int? CardId { get; init; }
|
||||||
public int? AccountId { get; init; }
|
public int? AccountId { get; init; }
|
||||||
public string? Last4 { get; init; }
|
public string? Last4 { get; init; }
|
||||||
public string? ErrorMessage { get; init; }
|
public string? ErrorMessage { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a successful result when a card is used.
|
/// Creates a successful result when a card is used.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static PaymentResolutionResult SuccessCard(int cardId, int accountId, string last4) =>
|
public static PaymentResolutionResult SuccessCard(int cardId, int accountId, string last4) =>
|
||||||
new() { IsSuccess = true, CardId = cardId, AccountId = accountId, Last4 = last4 };
|
new() { IsSuccess = true, CardId = cardId, AccountId = accountId, Last4 = last4 };
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a successful result when a direct account transaction (no card).
|
/// Creates a successful result when a direct account transaction (no card).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static PaymentResolutionResult SuccessAccount(int accountId, string last4) =>
|
public static PaymentResolutionResult SuccessAccount(int accountId, string last4) =>
|
||||||
new() { IsSuccess = true, AccountId = accountId, Last4 = last4 };
|
new() { IsSuccess = true, AccountId = accountId, Last4 = last4 };
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a failure result with error message.
|
/// Creates a failure result with error message.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static PaymentResolutionResult Failure(string error) =>
|
public static PaymentResolutionResult Failure(string error) =>
|
||||||
new() { IsSuccess = false, ErrorMessage = error };
|
new() { IsSuccess = false, ErrorMessage = error };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
namespace MoneyMap.Models.Import;
|
namespace MoneyMap.Models.Import;
|
||||||
|
|
||||||
public class TransactionCsvRow
|
public class TransactionCsvRow
|
||||||
{
|
{
|
||||||
public DateTime Date { get; set; }
|
public DateTime Date { get; set; }
|
||||||
public string Transaction { get; set; } = "";
|
public string Transaction { get; set; } = "";
|
||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
public string Memo { get; set; } = "";
|
public string Memo { get; set; } = "";
|
||||||
public decimal Amount { get; set; }
|
public decimal Amount { get; set; }
|
||||||
public string Category { get; set; } = "";
|
public string Category { get; set; } = "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
using CsvHelper.Configuration;
|
using CsvHelper.Configuration;
|
||||||
|
|
||||||
namespace MoneyMap.Models.Import;
|
namespace MoneyMap.Models.Import;
|
||||||
|
|
||||||
public sealed class TransactionCsvRowMap : ClassMap<TransactionCsvRow>
|
public sealed class TransactionCsvRowMap : ClassMap<TransactionCsvRow>
|
||||||
{
|
{
|
||||||
public TransactionCsvRowMap(bool hasCategory)
|
public TransactionCsvRowMap(bool hasCategory)
|
||||||
{
|
{
|
||||||
Map(m => m.Date).Name("Date");
|
Map(m => m.Date).Name("Date");
|
||||||
Map(m => m.Transaction).Name("Transaction");
|
Map(m => m.Transaction).Name("Transaction");
|
||||||
Map(m => m.Name).Name("Name");
|
Map(m => m.Name).Name("Name");
|
||||||
Map(m => m.Memo).Name("Memo");
|
Map(m => m.Memo).Name("Memo");
|
||||||
Map(m => m.Amount).Name("Amount");
|
Map(m => m.Amount).Name("Amount");
|
||||||
|
|
||||||
if (hasCategory)
|
if (hasCategory)
|
||||||
{
|
{
|
||||||
Map(m => m.Category).Name("Category");
|
Map(m => m.Category).Name("Category");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
if (hasCategory)
|
if (hasCategory)
|
||||||
{
|
{
|
||||||
Map(m => m.Category).Name("Category");
|
Map(m => m.Category).Name("Category");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Map(m => m.Category).Constant(string.Empty).Optional();
|
Map(m => m.Category).Constant(string.Empty).Optional();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace MoneyMap.Models;
|
namespace MoneyMap.Models;
|
||||||
|
|
||||||
public class Merchant
|
public class Merchant
|
||||||
{
|
{
|
||||||
[Key]
|
[Key]
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
[MaxLength(100)]
|
[MaxLength(100)]
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>();
|
public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>();
|
||||||
public ICollection<CategoryMapping> CategoryMappings { get; set; } = new List<CategoryMapping>();
|
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 Microsoft.EntityFrameworkCore;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
namespace MoneyMap.Models;
|
namespace MoneyMap.Models;
|
||||||
|
|
||||||
public enum ReceiptParseStatus
|
public enum ReceiptParseStatus
|
||||||
{
|
{
|
||||||
NotRequested = 0,
|
NotRequested = 0,
|
||||||
Queued = 1,
|
Queued = 1,
|
||||||
Parsing = 2,
|
Parsing = 2,
|
||||||
Completed = 3,
|
Completed = 3,
|
||||||
Failed = 4
|
Failed = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
[Index(nameof(TransactionId), nameof(FileHashSha256), IsUnique = true)]
|
[Index(nameof(TransactionId), nameof(FileHashSha256), IsUnique = true)]
|
||||||
public class Receipt
|
public class Receipt
|
||||||
{
|
{
|
||||||
[Key]
|
[Key]
|
||||||
public long Id { get; set; }
|
public long Id { get; set; }
|
||||||
|
|
||||||
// Link to transaction (nullable to support unmapped receipts)
|
// Link to transaction (nullable to support unmapped receipts)
|
||||||
public long? TransactionId { get; set; }
|
public long? TransactionId { get; set; }
|
||||||
public Transaction? Transaction { get; set; }
|
public Transaction? Transaction { get; set; }
|
||||||
|
|
||||||
// File metadata
|
// File metadata
|
||||||
[MaxLength(260)]
|
[MaxLength(260)]
|
||||||
public string FileName { get; set; } = string.Empty;
|
public string FileName { get; set; } = string.Empty;
|
||||||
|
|
||||||
[MaxLength(100)]
|
[MaxLength(100)]
|
||||||
public string ContentType { get; set; } = "application/octet-stream";
|
public string ContentType { get; set; } = "application/octet-stream";
|
||||||
|
|
||||||
[MaxLength(1024)]
|
[MaxLength(1024)]
|
||||||
public string StoragePath { get; set; } = string.Empty; // \\barge.lan\receipts\...\ or blob key
|
public string StoragePath { get; set; } = string.Empty; // \\barge.lan\receipts\...\ or blob key
|
||||||
|
|
||||||
public long FileSizeBytes { get; set; }
|
public long FileSizeBytes { get; set; }
|
||||||
|
|
||||||
[MaxLength(64)]
|
[MaxLength(64)]
|
||||||
public string FileHashSha256 { get; set; } = string.Empty; // for dedupe
|
public string FileHashSha256 { get; set; } = string.Empty; // for dedupe
|
||||||
|
|
||||||
public DateTime UploadedAtUtc { get; set; } = DateTime.UtcNow;
|
public DateTime UploadedAtUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
// Parsed header fields (optional, set by parser job)
|
// Parsed header fields (optional, set by parser job)
|
||||||
[MaxLength(200)]
|
[MaxLength(200)]
|
||||||
public string? Merchant { get; set; }
|
public string? Merchant { get; set; }
|
||||||
|
|
||||||
public DateTime? ReceiptDate { get; set; }
|
public DateTime? ReceiptDate { get; set; }
|
||||||
|
|
||||||
public DateTime? DueDate { get; set; } // For bills - payment due date
|
public DateTime? DueDate { get; set; } // For bills - payment due date
|
||||||
|
|
||||||
[Column(TypeName = "decimal(18,2)")]
|
[Column(TypeName = "decimal(18,2)")]
|
||||||
public decimal? Subtotal { get; set; }
|
public decimal? Subtotal { get; set; }
|
||||||
|
|
||||||
[Column(TypeName = "decimal(18,2)")]
|
[Column(TypeName = "decimal(18,2)")]
|
||||||
public decimal? Tax { get; set; }
|
public decimal? Tax { get; set; }
|
||||||
|
|
||||||
[Column(TypeName = "decimal(18,2)")]
|
[Column(TypeName = "decimal(18,2)")]
|
||||||
public decimal? Total { get; set; }
|
public decimal? Total { get; set; }
|
||||||
|
|
||||||
[MaxLength(8)]
|
[MaxLength(8)]
|
||||||
public string? Currency { get; set; }
|
public string? Currency { get; set; }
|
||||||
|
|
||||||
// User notes provided to AI parser
|
// User notes provided to AI parser
|
||||||
[MaxLength(2000)]
|
[MaxLength(2000)]
|
||||||
public string? ParsingNotes { get; set; }
|
public string? ParsingNotes { get; set; }
|
||||||
|
|
||||||
// Parse queue status
|
// Parse queue status
|
||||||
public ReceiptParseStatus ParseStatus { get; set; } = ReceiptParseStatus.NotRequested;
|
public ReceiptParseStatus ParseStatus { get; set; } = ReceiptParseStatus.NotRequested;
|
||||||
|
|
||||||
// One receipt -> many parse attempts + many line items
|
// One receipt -> many parse attempts + many line items
|
||||||
public ICollection<ReceiptParseLog> ParseLogs { get; set; } = new List<ReceiptParseLog>();
|
public ICollection<ReceiptParseLog> ParseLogs { get; set; } = new List<ReceiptParseLog>();
|
||||||
public ICollection<ReceiptLineItem> LineItems { get; set; } = new List<ReceiptLineItem>();
|
public ICollection<ReceiptLineItem> LineItems { get; set; } = new List<ReceiptLineItem>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,44 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
namespace MoneyMap.Models;
|
namespace MoneyMap.Models;
|
||||||
|
|
||||||
[Index(nameof(ReceiptId), nameof(LineNumber))]
|
[Index(nameof(ReceiptId), nameof(LineNumber))]
|
||||||
public class ReceiptLineItem
|
public class ReceiptLineItem
|
||||||
{
|
{
|
||||||
[Key]
|
[Key]
|
||||||
public long Id { get; set; }
|
public long Id { get; set; }
|
||||||
|
|
||||||
public long ReceiptId { get; set; }
|
public long ReceiptId { get; set; }
|
||||||
public Receipt Receipt { get; set; } = null!;
|
public Receipt Receipt { get; set; } = null!;
|
||||||
|
|
||||||
public int LineNumber { get; set; }
|
public int LineNumber { get; set; }
|
||||||
|
|
||||||
[MaxLength(300)]
|
[MaxLength(300)]
|
||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
// ReceiptLineItem
|
// ReceiptLineItem
|
||||||
[Column(TypeName = "decimal(18,4)")]
|
[Column(TypeName = "decimal(18,4)")]
|
||||||
public decimal? Quantity { get; set; }
|
public decimal? Quantity { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Unit of Measure (ea, lb, gal, etc.)
|
/// Unit of Measure (ea, lb, gal, etc.)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[MaxLength(16)]
|
[MaxLength(16)]
|
||||||
public string? Unit { get; set; }
|
public string? Unit { get; set; }
|
||||||
|
|
||||||
[Column(TypeName = "decimal(18,4)")]
|
[Column(TypeName = "decimal(18,4)")]
|
||||||
public decimal? UnitPrice { get; set; }
|
public decimal? UnitPrice { get; set; }
|
||||||
|
|
||||||
[Column(TypeName = "decimal(18,2)")]
|
[Column(TypeName = "decimal(18,2)")]
|
||||||
public decimal? LineTotal { get; set; }
|
public decimal? LineTotal { get; set; }
|
||||||
|
|
||||||
[MaxLength(64)]
|
[MaxLength(64)]
|
||||||
public string? Sku { get; set; }
|
public string? Sku { get; set; }
|
||||||
|
|
||||||
[MaxLength(100)]
|
[MaxLength(100)]
|
||||||
public string? Category { get; set; }
|
public string? Category { get; set; }
|
||||||
|
|
||||||
public bool Voided { get; set; }
|
public bool Voided { get; set; }
|
||||||
}
|
}
|
||||||
@@ -1,43 +1,43 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
namespace MoneyMap.Models;
|
namespace MoneyMap.Models;
|
||||||
|
|
||||||
[Index(nameof(ReceiptId), nameof(StartedAtUtc))]
|
[Index(nameof(ReceiptId), nameof(StartedAtUtc))]
|
||||||
public class ReceiptParseLog
|
public class ReceiptParseLog
|
||||||
{
|
{
|
||||||
[Key]
|
[Key]
|
||||||
public long Id { get; set; }
|
public long Id { get; set; }
|
||||||
|
|
||||||
public long ReceiptId { get; set; }
|
public long ReceiptId { get; set; }
|
||||||
public Receipt Receipt { get; set; } = null!;
|
public Receipt Receipt { get; set; } = null!;
|
||||||
|
|
||||||
// Provider metadata as strings for flexibility
|
// Provider metadata as strings for flexibility
|
||||||
[MaxLength(50)]
|
[MaxLength(50)]
|
||||||
public string Provider { get; set; } = string.Empty; // e.g., "OpenAI", "Azure", "Google", "Tesseract"
|
public string Provider { get; set; } = string.Empty; // e.g., "OpenAI", "Azure", "Google", "Tesseract"
|
||||||
|
|
||||||
[MaxLength(100)]
|
[MaxLength(100)]
|
||||||
public string Model { get; set; } = string.Empty; // e.g., "gpt-4o-mini"
|
public string Model { get; set; } = string.Empty; // e.g., "gpt-4o-mini"
|
||||||
|
|
||||||
[MaxLength(100)]
|
[MaxLength(100)]
|
||||||
public string? ProviderJobId { get; set; }
|
public string? ProviderJobId { get; set; }
|
||||||
|
|
||||||
public DateTime StartedAtUtc { get; set; } = DateTime.UtcNow;
|
public DateTime StartedAtUtc { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime? CompletedAtUtc { get; set; }
|
public DateTime? CompletedAtUtc { get; set; }
|
||||||
public bool Success { get; set; }
|
public bool Success { get; set; }
|
||||||
|
|
||||||
// ReceiptParseLog
|
// ReceiptParseLog
|
||||||
|
|
||||||
[Column(TypeName = "decimal(5,4)")]
|
[Column(TypeName = "decimal(5,4)")]
|
||||||
public decimal? Confidence { get; set; } // 0.0000–0.9999 is plenty
|
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)
|
// Store full provider JSON payload for re-parsing/debug (keep out of hot paths)
|
||||||
public string RawProviderPayloadJson { get; set; } = "{}";
|
public string RawProviderPayloadJson { get; set; } = "{}";
|
||||||
|
|
||||||
// Optional extracted text path if you persist a .txt alongside the image/PDF
|
// Optional extracted text path if you persist a .txt alongside the image/PDF
|
||||||
[MaxLength(1024)]
|
[MaxLength(1024)]
|
||||||
public string? ExtractedTextPath { get; set; }
|
public string? ExtractedTextPath { get; set; }
|
||||||
|
|
||||||
public string? Error { 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 Microsoft.EntityFrameworkCore;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
namespace MoneyMap.Models;
|
namespace MoneyMap.Models;
|
||||||
|
|
||||||
[Index(nameof(Date), nameof(Amount), nameof(Name), nameof(Memo), nameof(AccountId), nameof(CardId), IsUnique = true)]
|
[Index(nameof(Date), nameof(Amount), nameof(Name), nameof(Memo), nameof(AccountId), nameof(CardId), IsUnique = true)]
|
||||||
public class Transaction
|
public class Transaction
|
||||||
{
|
{
|
||||||
[Key]
|
[Key]
|
||||||
public long Id { get; set; }
|
public long Id { get; set; }
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public DateTime Date { get; set; }
|
public DateTime Date { get; set; }
|
||||||
|
|
||||||
[MaxLength(20)]
|
[MaxLength(20)]
|
||||||
public string TransactionType { get; set; } = string.Empty; // "DEBIT"/"CREDIT" if present
|
public string TransactionType { get; set; } = string.Empty; // "DEBIT"/"CREDIT" if present
|
||||||
|
|
||||||
[MaxLength(200)]
|
[MaxLength(200)]
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
[MaxLength(500)]
|
[MaxLength(500)]
|
||||||
public string Memo { get; set; } = string.Empty;
|
public string Memo { get; set; } = string.Empty;
|
||||||
|
|
||||||
[Column(TypeName = "decimal(18,2)")]
|
[Column(TypeName = "decimal(18,2)")]
|
||||||
public decimal Amount { get; set; } // negative = debit, positive = credit
|
public decimal Amount { get; set; } // negative = debit, positive = credit
|
||||||
|
|
||||||
[MaxLength(100)]
|
[MaxLength(100)]
|
||||||
public string Category { get; set; } = string.Empty;
|
public string Category { get; set; } = string.Empty;
|
||||||
|
|
||||||
// Merchant relationship
|
// Merchant relationship
|
||||||
[ForeignKey(nameof(Merchant))]
|
[ForeignKey(nameof(Merchant))]
|
||||||
public int? MerchantId { get; set; }
|
public int? MerchantId { get; set; }
|
||||||
public Merchant? Merchant { get; set; }
|
public Merchant? Merchant { get; set; }
|
||||||
|
|
||||||
public string Notes { get; set; } = string.Empty;
|
public string Notes { get; set; } = string.Empty;
|
||||||
|
|
||||||
// Primary container: Every transaction belongs to an Account (the source of CSV)
|
// Primary container: Every transaction belongs to an Account (the source of CSV)
|
||||||
[Required]
|
[Required]
|
||||||
[ForeignKey(nameof(Account))]
|
[ForeignKey(nameof(Account))]
|
||||||
public int AccountId { get; set; }
|
public int AccountId { get; set; }
|
||||||
public Account Account { get; set; } = null!;
|
public Account Account { get; set; } = null!;
|
||||||
|
|
||||||
// Optional: Card used for this transaction (if it was a card payment)
|
// Optional: Card used for this transaction (if it was a card payment)
|
||||||
[ForeignKey(nameof(Card))]
|
[ForeignKey(nameof(Card))]
|
||||||
public int? CardId { get; set; }
|
public int? CardId { get; set; }
|
||||||
public Card? Card { get; set; }
|
public Card? Card { get; set; }
|
||||||
|
|
||||||
// Optional: For transfers between accounts, this links to the destination account
|
// Optional: For transfers between accounts, this links to the destination account
|
||||||
// This transaction is the "source" side of the transfer (debit)
|
// This transaction is the "source" side of the transfer (debit)
|
||||||
// The matching transaction in the destination account has this AccountId as its TransferToAccountId
|
// The matching transaction in the destination account has this AccountId as its TransferToAccountId
|
||||||
[ForeignKey(nameof(TransferToAccount))]
|
[ForeignKey(nameof(TransferToAccount))]
|
||||||
public int? TransferToAccountId { get; set; }
|
public int? TransferToAccountId { get; set; }
|
||||||
public Account? TransferToAccount { get; set; }
|
public Account? TransferToAccount { get; set; }
|
||||||
|
|
||||||
[MaxLength(4)]
|
[MaxLength(4)]
|
||||||
public string? Last4 { get; set; } // parsed from Memo if present
|
public string? Last4 { get; set; } // parsed from Memo if present
|
||||||
|
|
||||||
public ICollection<Receipt> Receipts { get; set; } = new List<Receipt>();
|
public ICollection<Receipt> Receipts { get; set; } = new List<Receipt>();
|
||||||
|
|
||||||
[NotMapped] public bool IsCredit => Amount > 0;
|
[NotMapped] public bool IsCredit => Amount > 0;
|
||||||
[NotMapped] public bool IsDebit => Amount < 0;
|
[NotMapped] public bool IsDebit => Amount < 0;
|
||||||
[NotMapped] public bool IsTransfer => TransferToAccountId.HasValue;
|
[NotMapped] public bool IsTransfer => TransferToAccountId.HasValue;
|
||||||
|
|
||||||
[NotMapped]
|
[NotMapped]
|
||||||
public string PaymentMethodLabel
|
public string PaymentMethodLabel
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
// Handle transfers
|
// Handle transfers
|
||||||
if (IsTransfer)
|
if (IsTransfer)
|
||||||
{
|
{
|
||||||
var toLabel = TransferToAccount?.DisplayLabel ?? "Unknown";
|
var toLabel = TransferToAccount?.DisplayLabel ?? "Unknown";
|
||||||
return $"Transfer → {toLabel}";
|
return $"Transfer → {toLabel}";
|
||||||
}
|
}
|
||||||
|
|
||||||
// If card was used, show just the card (since account is implied)
|
// If card was used, show just the card (since account is implied)
|
||||||
if (Card != null)
|
if (Card != null)
|
||||||
return Card.DisplayLabel;
|
return Card.DisplayLabel;
|
||||||
|
|
||||||
// Direct account transaction (no card)
|
// Direct account transaction (no card)
|
||||||
return Account?.DisplayLabel ?? $"···· {Last4}";
|
return Account?.DisplayLabel ?? $"···· {Last4}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,49 +1,49 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
namespace MoneyMap.Models;
|
namespace MoneyMap.Models;
|
||||||
|
|
||||||
public class Transfer
|
public class Transfer
|
||||||
{
|
{
|
||||||
[Key]
|
[Key]
|
||||||
public long Id { get; set; }
|
public long Id { get; set; }
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public DateTime Date { get; set; }
|
public DateTime Date { get; set; }
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
[Column(TypeName = "decimal(18,2)")]
|
[Column(TypeName = "decimal(18,2)")]
|
||||||
public decimal Amount { get; set; } // Always positive
|
public decimal Amount { get; set; } // Always positive
|
||||||
|
|
||||||
[MaxLength(500)]
|
[MaxLength(500)]
|
||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string Notes { get; set; } = string.Empty;
|
public string Notes { get; set; } = string.Empty;
|
||||||
|
|
||||||
// Source account (where money comes from) - nullable for "Unknown"
|
// Source account (where money comes from) - nullable for "Unknown"
|
||||||
[ForeignKey(nameof(SourceAccount))]
|
[ForeignKey(nameof(SourceAccount))]
|
||||||
public int? SourceAccountId { get; set; }
|
public int? SourceAccountId { get; set; }
|
||||||
public Account? SourceAccount { get; set; }
|
public Account? SourceAccount { get; set; }
|
||||||
|
|
||||||
// Destination account (where money goes to) - nullable for "Unknown"
|
// Destination account (where money goes to) - nullable for "Unknown"
|
||||||
[ForeignKey(nameof(DestinationAccount))]
|
[ForeignKey(nameof(DestinationAccount))]
|
||||||
public int? DestinationAccountId { get; set; }
|
public int? DestinationAccountId { get; set; }
|
||||||
public Account? DestinationAccount { get; set; }
|
public Account? DestinationAccount { get; set; }
|
||||||
|
|
||||||
// Optional link to original transaction if imported from CSV
|
// Optional link to original transaction if imported from CSV
|
||||||
[ForeignKey(nameof(OriginalTransaction))]
|
[ForeignKey(nameof(OriginalTransaction))]
|
||||||
public long? OriginalTransactionId { get; set; }
|
public long? OriginalTransactionId { get; set; }
|
||||||
public Transaction? OriginalTransaction { get; set; }
|
public Transaction? OriginalTransaction { get; set; }
|
||||||
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
[NotMapped]
|
[NotMapped]
|
||||||
public string SourceLabel => SourceAccount != null
|
public string SourceLabel => SourceAccount != null
|
||||||
? $"{SourceAccount.Institution} {SourceAccount.Last4}"
|
? $"{SourceAccount.Institution} {SourceAccount.Last4}"
|
||||||
: "Unknown";
|
: "Unknown";
|
||||||
|
|
||||||
[NotMapped]
|
[NotMapped]
|
||||||
public string DestinationLabel => DestinationAccount != null
|
public string DestinationLabel => DestinationAccount != null
|
||||||
? $"{DestinationAccount.Institution} {DestinationAccount.Last4}"
|
? $"{DestinationAccount.Institution} {DestinationAccount.Last4}"
|
||||||
: "Unknown";
|
: "Unknown";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
||||||
<PackageReference Include="Magick.NET-Q16-AnyCPU" Version="14.8.2" />
|
<PackageReference Include="Magick.NET-Q16-AnyCPU" Version="14.8.2" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" />
|
||||||
<PackageReference Include="PdfPig" Version="0.1.11" />
|
<PackageReference Include="PdfPig" Version="0.1.11" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Update="Prompts\**\*">
|
<None Update="Prompts\**\*">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</None>
|
</None>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</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.
|
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",
|
"merchant": "store name",
|
||||||
"receiptDate": "YYYY-MM-DD",
|
"receiptDate": "YYYY-MM-DD",
|
||||||
"dueDate": null,
|
"dueDate": null,
|
||||||
"subtotal": 0.00,
|
"subtotal": 0.00,
|
||||||
"tax": 0.00,
|
"tax": 0.00,
|
||||||
"total": 0.00,
|
"total": 0.00,
|
||||||
"confidence": 0.95,
|
"confidence": 0.95,
|
||||||
"suggestedCategory": null,
|
"suggestedCategory": null,
|
||||||
"suggestedTransactionId": null,
|
"suggestedTransactionId": null,
|
||||||
"lineItems": [
|
"lineItems": [
|
||||||
{
|
{
|
||||||
"description": "item name",
|
"description": "item name",
|
||||||
"upc": null,
|
"upc": null,
|
||||||
"quantity": 1.0,
|
"quantity": 1.0,
|
||||||
"unitPrice": 0.00,
|
"unitPrice": 0.00,
|
||||||
"lineTotal": 0.00,
|
"lineTotal": 0.00,
|
||||||
"category": null,
|
"category": null,
|
||||||
"voided": false
|
"voided": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
FIELD TYPES (you must follow these exactly):
|
FIELD TYPES (you must follow these exactly):
|
||||||
- merchant: string
|
- merchant: string
|
||||||
- receiptDate: string "YYYY-MM-DD" or null
|
- receiptDate: string "YYYY-MM-DD" or null
|
||||||
- dueDate: string "YYYY-MM-DD" or null (only for bills with a payment deadline)
|
- dueDate: string "YYYY-MM-DD" or null (only for bills with a payment deadline)
|
||||||
- subtotal: number or null
|
- subtotal: number or null
|
||||||
- tax: number or null
|
- tax: number or null
|
||||||
- total: number
|
- total: number
|
||||||
- confidence: number between 0 and 1
|
- confidence: number between 0 and 1
|
||||||
- suggestedCategory: string or null
|
- suggestedCategory: string or null
|
||||||
- suggestedTransactionId: integer or null (MUST be a JSON number like 123, NEVER a string like "123")
|
- suggestedTransactionId: integer or null (MUST be a JSON number like 123, NEVER a string like "123")
|
||||||
- lineItems: array of objects
|
- lineItems: array of objects
|
||||||
|
|
||||||
LINE ITEM FIELDS:
|
LINE ITEM FIELDS:
|
||||||
- description: string (the item or service name, include count/size info like "4CT" or "12 OZ")
|
- 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)
|
- 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)
|
- 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)
|
- 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)
|
- lineTotal: number (the price shown on the receipt; 0.00 if voided)
|
||||||
- category: string or null
|
- category: string or null
|
||||||
- voided: boolean
|
- voided: boolean
|
||||||
|
|
||||||
RULES FOR LINE ITEMS:
|
RULES FOR LINE ITEMS:
|
||||||
- Extract ALL line items from top to bottom - never stop early
|
- 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.
|
- 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
|
- 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
|
- UPC/barcode numbers are long numeric codes (12-13 digits) near the item
|
||||||
|
|
||||||
VOIDED ITEMS:
|
VOIDED ITEMS:
|
||||||
- When you see "** VOIDED ENTRY **" or similar, the item immediately after it is voided
|
- 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 voided items: set "voided": true and "lineTotal": 0.00
|
||||||
- For all other items: set "voided": false
|
- For all other items: set "voided": false
|
||||||
- NEVER skip voided items - include them in the lineItems array
|
- NEVER skip voided items - include them in the lineItems array
|
||||||
- CONTINUE reading ALL items after void markers
|
- CONTINUE reading ALL items after void markers
|
||||||
|
|
||||||
DUE DATE:
|
DUE DATE:
|
||||||
- Only for bills (utility, credit card, etc.) - extract the payment due date
|
- Only for bills (utility, credit card, etc.) - extract the payment due date
|
||||||
- For regular store receipts, dueDate must be null
|
- For regular store receipts, dueDate must be null
|
||||||
@@ -1,53 +1,53 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using MoneyMap.Data;
|
using MoneyMap.Data;
|
||||||
using MoneyMap.Services;
|
using MoneyMap.Services;
|
||||||
using MoneyMap.Services.AITools;
|
using MoneyMap.Services.AITools;
|
||||||
|
|
||||||
namespace MoneyMap.Core;
|
namespace MoneyMap.Core;
|
||||||
|
|
||||||
public static class ServiceCollectionExtensions
|
public static class ServiceCollectionExtensions
|
||||||
{
|
{
|
||||||
public static IServiceCollection AddMoneyMapCore(
|
public static IServiceCollection AddMoneyMapCore(
|
||||||
this IServiceCollection services, IConfiguration configuration)
|
this IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
services.AddDbContext<MoneyMapContext>(options =>
|
services.AddDbContext<MoneyMapContext>(options =>
|
||||||
options.UseSqlServer(configuration.GetConnectionString("MoneyMapDb")));
|
options.UseSqlServer(configuration.GetConnectionString("MoneyMapDb")));
|
||||||
|
|
||||||
services.AddMemoryCache();
|
services.AddMemoryCache();
|
||||||
|
|
||||||
// Core transaction and import services
|
// Core transaction and import services
|
||||||
services.AddScoped<ITransactionImporter, TransactionImporter>();
|
services.AddScoped<ITransactionImporter, TransactionImporter>();
|
||||||
services.AddScoped<ICardResolver, CardResolver>();
|
services.AddScoped<ICardResolver, CardResolver>();
|
||||||
services.AddScoped<ITransactionCategorizer, TransactionCategorizer>();
|
services.AddScoped<ITransactionCategorizer, TransactionCategorizer>();
|
||||||
services.AddScoped<ITransactionService, TransactionService>();
|
services.AddScoped<ITransactionService, TransactionService>();
|
||||||
services.AddScoped<ITransactionStatisticsService, TransactionStatisticsService>();
|
services.AddScoped<ITransactionStatisticsService, TransactionStatisticsService>();
|
||||||
|
|
||||||
// Entity management services
|
// Entity management services
|
||||||
services.AddScoped<IAccountService, AccountService>();
|
services.AddScoped<IAccountService, AccountService>();
|
||||||
services.AddScoped<ICardService, CardService>();
|
services.AddScoped<ICardService, CardService>();
|
||||||
services.AddScoped<IMerchantService, MerchantService>();
|
services.AddScoped<IMerchantService, MerchantService>();
|
||||||
services.AddScoped<IBudgetService, BudgetService>();
|
services.AddScoped<IBudgetService, BudgetService>();
|
||||||
|
|
||||||
// Receipt services
|
// Receipt services
|
||||||
services.AddScoped<IReceiptMatchingService, ReceiptMatchingService>();
|
services.AddScoped<IReceiptMatchingService, ReceiptMatchingService>();
|
||||||
services.AddScoped<IReceiptManager, ReceiptManager>();
|
services.AddScoped<IReceiptManager, ReceiptManager>();
|
||||||
services.AddScoped<IReceiptAutoMapper, ReceiptAutoMapper>();
|
services.AddScoped<IReceiptAutoMapper, ReceiptAutoMapper>();
|
||||||
services.AddScoped<IPdfToImageConverter, PdfToImageConverter>();
|
services.AddScoped<IPdfToImageConverter, PdfToImageConverter>();
|
||||||
|
|
||||||
// Reference data and dashboard
|
// Reference data and dashboard
|
||||||
services.AddScoped<IReferenceDataService, ReferenceDataService>();
|
services.AddScoped<IReferenceDataService, ReferenceDataService>();
|
||||||
services.AddScoped<IDashboardService, DashboardService>();
|
services.AddScoped<IDashboardService, DashboardService>();
|
||||||
services.AddScoped<IDashboardStatsCalculator, DashboardStatsCalculator>();
|
services.AddScoped<IDashboardStatsCalculator, DashboardStatsCalculator>();
|
||||||
services.AddScoped<ITopCategoriesProvider, TopCategoriesProvider>();
|
services.AddScoped<ITopCategoriesProvider, TopCategoriesProvider>();
|
||||||
services.AddScoped<IRecentTransactionsProvider, RecentTransactionsProvider>();
|
services.AddScoped<IRecentTransactionsProvider, RecentTransactionsProvider>();
|
||||||
services.AddScoped<ISpendTrendsProvider, SpendTrendsProvider>();
|
services.AddScoped<ISpendTrendsProvider, SpendTrendsProvider>();
|
||||||
|
|
||||||
// AI services
|
// AI services
|
||||||
services.AddScoped<IAIToolExecutor, AIToolExecutor>();
|
services.AddScoped<IAIToolExecutor, AIToolExecutor>();
|
||||||
services.AddScoped<IFinancialAuditService, FinancialAuditService>();
|
services.AddScoped<IFinancialAuditService, FinancialAuditService>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,466 +1,466 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using MoneyMap.Data;
|
using MoneyMap.Data;
|
||||||
using MoneyMap.Models;
|
using MoneyMap.Models;
|
||||||
using MoneyMap.Services.AITools;
|
using MoneyMap.Services.AITools;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace MoneyMap.Services
|
namespace MoneyMap.Services
|
||||||
{
|
{
|
||||||
public interface IReceiptParser
|
public interface IReceiptParser
|
||||||
{
|
{
|
||||||
Task<ReceiptParseResult> ParseReceiptAsync(long receiptId, string? model = null, string? notes = null);
|
Task<ReceiptParseResult> ParseReceiptAsync(long receiptId, string? model = null, string? notes = null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AIReceiptParser : IReceiptParser
|
public class AIReceiptParser : IReceiptParser
|
||||||
{
|
{
|
||||||
private readonly MoneyMapContext _db;
|
private readonly MoneyMapContext _db;
|
||||||
private readonly IReceiptManager _receiptManager;
|
private readonly IReceiptManager _receiptManager;
|
||||||
private readonly IPdfToImageConverter _pdfConverter;
|
private readonly IPdfToImageConverter _pdfConverter;
|
||||||
private readonly IAIVisionClientResolver _clientResolver;
|
private readonly IAIVisionClientResolver _clientResolver;
|
||||||
private readonly IMerchantService _merchantService;
|
private readonly IMerchantService _merchantService;
|
||||||
private readonly IAIToolExecutor _toolExecutor;
|
private readonly IAIToolExecutor _toolExecutor;
|
||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly ILogger<AIReceiptParser> _logger;
|
private readonly ILogger<AIReceiptParser> _logger;
|
||||||
private string? _promptTemplate;
|
private string? _promptTemplate;
|
||||||
|
|
||||||
public AIReceiptParser(
|
public AIReceiptParser(
|
||||||
MoneyMapContext db,
|
MoneyMapContext db,
|
||||||
IReceiptManager receiptManager,
|
IReceiptManager receiptManager,
|
||||||
IPdfToImageConverter pdfConverter,
|
IPdfToImageConverter pdfConverter,
|
||||||
IAIVisionClientResolver clientResolver,
|
IAIVisionClientResolver clientResolver,
|
||||||
IMerchantService merchantService,
|
IMerchantService merchantService,
|
||||||
IAIToolExecutor toolExecutor,
|
IAIToolExecutor toolExecutor,
|
||||||
IServiceProvider serviceProvider,
|
IServiceProvider serviceProvider,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
ILogger<AIReceiptParser> logger)
|
ILogger<AIReceiptParser> logger)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_receiptManager = receiptManager;
|
_receiptManager = receiptManager;
|
||||||
_pdfConverter = pdfConverter;
|
_pdfConverter = pdfConverter;
|
||||||
_clientResolver = clientResolver;
|
_clientResolver = clientResolver;
|
||||||
_merchantService = merchantService;
|
_merchantService = merchantService;
|
||||||
_toolExecutor = toolExecutor;
|
_toolExecutor = toolExecutor;
|
||||||
_serviceProvider = serviceProvider;
|
_serviceProvider = serviceProvider;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ReceiptParseResult> ParseReceiptAsync(long receiptId, string? model = null, string? notes = null)
|
public async Task<ReceiptParseResult> ParseReceiptAsync(long receiptId, string? model = null, string? notes = null)
|
||||||
{
|
{
|
||||||
var receipt = await _db.Receipts
|
var receipt = await _db.Receipts
|
||||||
.Include(r => r.Transaction)
|
.Include(r => r.Transaction)
|
||||||
.FirstOrDefaultAsync(r => r.Id == receiptId);
|
.FirstOrDefaultAsync(r => r.Id == receiptId);
|
||||||
|
|
||||||
if (receipt == null)
|
if (receipt == null)
|
||||||
return ReceiptParseResult.Failure("Receipt not found.");
|
return ReceiptParseResult.Failure("Receipt not found.");
|
||||||
|
|
||||||
var filePath = _receiptManager.GetReceiptPhysicalPath(receipt);
|
var filePath = _receiptManager.GetReceiptPhysicalPath(receipt);
|
||||||
if (!File.Exists(filePath))
|
if (!File.Exists(filePath))
|
||||||
return ReceiptParseResult.Failure("Receipt file not found on disk.");
|
return ReceiptParseResult.Failure("Receipt file not found on disk.");
|
||||||
|
|
||||||
// Fall back to receipt.ParsingNotes if notes parameter is null
|
// Fall back to receipt.ParsingNotes if notes parameter is null
|
||||||
var effectiveNotes = notes ?? receipt.ParsingNotes;
|
var effectiveNotes = notes ?? receipt.ParsingNotes;
|
||||||
|
|
||||||
var selectedModel = model ?? _configuration["AI:ReceiptParsingModel"] ?? "gpt-4o-mini";
|
var selectedModel = model ?? _configuration["AI:ReceiptParsingModel"] ?? "gpt-4o-mini";
|
||||||
var (client, provider) = _clientResolver.Resolve(selectedModel);
|
var (client, provider) = _clientResolver.Resolve(selectedModel);
|
||||||
|
|
||||||
// Let model-aware clients evaluate tool support for the specific model
|
// Let model-aware clients evaluate tool support for the specific model
|
||||||
if (client is LlamaCppVisionClient llamaCpp)
|
if (client is LlamaCppVisionClient llamaCpp)
|
||||||
llamaCpp.SetCurrentModel(selectedModel);
|
llamaCpp.SetCurrentModel(selectedModel);
|
||||||
|
|
||||||
var parseLog = new ReceiptParseLog
|
var parseLog = new ReceiptParseLog
|
||||||
{
|
{
|
||||||
ReceiptId = receiptId,
|
ReceiptId = receiptId,
|
||||||
Provider = provider,
|
Provider = provider,
|
||||||
Model = selectedModel,
|
Model = selectedModel,
|
||||||
StartedAtUtc = DateTime.UtcNow,
|
StartedAtUtc = DateTime.UtcNow,
|
||||||
Success = false
|
Success = false
|
||||||
};
|
};
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var (base64Data, mediaType) = await PrepareImageDataAsync(receipt, filePath);
|
var (base64Data, mediaType) = await PrepareImageDataAsync(receipt, filePath);
|
||||||
var promptText = await BuildPromptAsync(receipt, effectiveNotes, client);
|
var promptText = await BuildPromptAsync(receipt, effectiveNotes, client);
|
||||||
var visionResult = await CallVisionClientAsync(client, base64Data, mediaType, promptText, selectedModel);
|
var visionResult = await CallVisionClientAsync(client, base64Data, mediaType, promptText, selectedModel);
|
||||||
|
|
||||||
if (!visionResult.IsSuccess)
|
if (!visionResult.IsSuccess)
|
||||||
{
|
{
|
||||||
await SaveParseLogAsync(parseLog, visionResult.ErrorMessage);
|
await SaveParseLogAsync(parseLog, visionResult.ErrorMessage);
|
||||||
return ReceiptParseResult.Failure(visionResult.ErrorMessage!);
|
return ReceiptParseResult.Failure(visionResult.ErrorMessage!);
|
||||||
}
|
}
|
||||||
|
|
||||||
var parseData = ParseResponse(visionResult.Content);
|
var parseData = ParseResponse(visionResult.Content);
|
||||||
await ApplyParseResultAsync(receipt, receiptId, parseData, effectiveNotes);
|
await ApplyParseResultAsync(receipt, receiptId, parseData, effectiveNotes);
|
||||||
|
|
||||||
parseLog.Success = true;
|
parseLog.Success = true;
|
||||||
parseLog.Confidence = parseData.Confidence;
|
parseLog.Confidence = parseData.Confidence;
|
||||||
parseLog.RawProviderPayloadJson = JsonSerializer.Serialize(parseData);
|
parseLog.RawProviderPayloadJson = JsonSerializer.Serialize(parseData);
|
||||||
await SaveParseLogAsync(parseLog);
|
await SaveParseLogAsync(parseLog);
|
||||||
|
|
||||||
await TryAutoMapReceiptAsync(receipt, receiptId, parseData.SuggestedTransactionId);
|
await TryAutoMapReceiptAsync(receipt, receiptId, parseData.SuggestedTransactionId);
|
||||||
|
|
||||||
var lineCount = parseData.LineItems.Count;
|
var lineCount = parseData.LineItems.Count;
|
||||||
return ReceiptParseResult.Success($"Parsed {lineCount} line items from receipt.");
|
return ReceiptParseResult.Success($"Parsed {lineCount} line items from receipt.");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await SaveParseLogAsync(parseLog, ex.Message);
|
await SaveParseLogAsync(parseLog, ex.Message);
|
||||||
_logger.LogError(ex, "Error parsing receipt {ReceiptId}: {Message}", receiptId, ex.Message);
|
_logger.LogError(ex, "Error parsing receipt {ReceiptId}: {Message}", receiptId, ex.Message);
|
||||||
return ReceiptParseResult.Failure($"Error parsing receipt: {ex.Message}");
|
return ReceiptParseResult.Failure($"Error parsing receipt: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Call the vision client, using tool-use if the client supports it, or enriched prompt fallback for Ollama.
|
/// Call the vision client, using tool-use if the client supports it, or enriched prompt fallback for Ollama.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<VisionApiResult> CallVisionClientAsync(
|
private async Task<VisionApiResult> CallVisionClientAsync(
|
||||||
IAIVisionClient client, string base64Data, string mediaType, string prompt, string model)
|
IAIVisionClient client, string base64Data, string mediaType, string prompt, string model)
|
||||||
{
|
{
|
||||||
if (client is IAIToolAwareVisionClient toolAwareClient && toolAwareClient.SupportsToolUse)
|
if (client is IAIToolAwareVisionClient toolAwareClient && toolAwareClient.SupportsToolUse)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Using tool-aware vision client for model {Model}", model);
|
_logger.LogInformation("Using tool-aware vision client for model {Model}", model);
|
||||||
var tools = AIToolRegistry.GetAllTools();
|
var tools = AIToolRegistry.GetAllTools();
|
||||||
|
|
||||||
return await toolAwareClient.AnalyzeImageWithToolsAsync(
|
return await toolAwareClient.AnalyzeImageWithToolsAsync(
|
||||||
base64Data, mediaType, prompt, model,
|
base64Data, mediaType, prompt, model,
|
||||||
tools,
|
tools,
|
||||||
toolCall => _toolExecutor.ExecuteAsync(toolCall),
|
toolCall => _toolExecutor.ExecuteAsync(toolCall),
|
||||||
maxToolRounds: 5);
|
maxToolRounds: 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: standard call (Ollama gets enriched prompt via BuildPromptAsync)
|
// Fallback: standard call (Ollama gets enriched prompt via BuildPromptAsync)
|
||||||
_logger.LogInformation("Using standard vision client for model {Model} (no tool use)", model);
|
_logger.LogInformation("Using standard vision client for model {Model} (no tool use)", model);
|
||||||
return await client.AnalyzeImageAsync(base64Data, mediaType, prompt, model);
|
return await client.AnalyzeImageAsync(base64Data, mediaType, prompt, model);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<(string Base64Data, string MediaType)> PrepareImageDataAsync(Receipt receipt, string filePath)
|
private async Task<(string Base64Data, string MediaType)> PrepareImageDataAsync(Receipt receipt, string filePath)
|
||||||
{
|
{
|
||||||
if (receipt.ContentType == "application/pdf")
|
if (receipt.ContentType == "application/pdf")
|
||||||
{
|
{
|
||||||
var base64 = await _pdfConverter.ConvertFirstPageToBase64Async(filePath);
|
var base64 = await _pdfConverter.ConvertFirstPageToBase64Async(filePath);
|
||||||
return (base64, "image/png");
|
return (base64, "image/png");
|
||||||
}
|
}
|
||||||
|
|
||||||
var fileBytes = await File.ReadAllBytesAsync(filePath);
|
var fileBytes = await File.ReadAllBytesAsync(filePath);
|
||||||
return (Convert.ToBase64String(fileBytes), receipt.ContentType);
|
return (Convert.ToBase64String(fileBytes), receipt.ContentType);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> BuildPromptAsync(Receipt receipt, string? userNotes, IAIVisionClient client)
|
private async Task<string> BuildPromptAsync(Receipt receipt, string? userNotes, IAIVisionClient client)
|
||||||
{
|
{
|
||||||
var promptText = await LoadPromptTemplateAsync();
|
var promptText = await LoadPromptTemplateAsync();
|
||||||
|
|
||||||
var transactionName = receipt.Transaction?.Name;
|
var transactionName = receipt.Transaction?.Name;
|
||||||
if (!string.IsNullOrWhiteSpace(transactionName))
|
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.";
|
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"];
|
var parsingNotes = _configuration["AI:ReceiptParsingNotes"];
|
||||||
if (!string.IsNullOrWhiteSpace(parsingNotes))
|
if (!string.IsNullOrWhiteSpace(parsingNotes))
|
||||||
{
|
{
|
||||||
promptText += $"\n\nAdditional notes: {parsingNotes}";
|
promptText += $"\n\nAdditional notes: {parsingNotes}";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(userNotes))
|
if (!string.IsNullOrWhiteSpace(userNotes))
|
||||||
{
|
{
|
||||||
promptText += $"\n\nUser notes for this receipt: {userNotes}";
|
promptText += $"\n\nUser notes for this receipt: {userNotes}";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add tool-use or enriched context instructions based on client capability
|
// Add tool-use or enriched context instructions based on client capability
|
||||||
if (client is IAIToolAwareVisionClient toolAwareClient && toolAwareClient.SupportsToolUse)
|
if (client is IAIToolAwareVisionClient toolAwareClient && toolAwareClient.SupportsToolUse)
|
||||||
{
|
{
|
||||||
// Tool-aware client: instruct to use tools for lookups
|
// Tool-aware client: instruct to use tools for lookups
|
||||||
promptText += @"
|
promptText += @"
|
||||||
|
|
||||||
TOOL USE INSTRUCTIONS:
|
TOOL USE INSTRUCTIONS:
|
||||||
You have access to tools that can query the application's database. You MUST call them before generating your JSON response:
|
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.
|
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.
|
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.";
|
3. Call search_merchants to look up the correct merchant name.";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Non-tool client (Ollama): inject pre-fetched database context
|
// Non-tool client (Ollama): inject pre-fetched database context
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var merchantHint = receipt.Transaction?.Name ?? receipt.Merchant;
|
var merchantHint = receipt.Transaction?.Name ?? receipt.Merchant;
|
||||||
var enrichedContext = await _toolExecutor.GetEnrichedContextAsync(
|
var enrichedContext = await _toolExecutor.GetEnrichedContextAsync(
|
||||||
receipt.ReceiptDate,
|
receipt.ReceiptDate,
|
||||||
receipt.Total,
|
receipt.Total,
|
||||||
merchantHint);
|
merchantHint);
|
||||||
|
|
||||||
promptText += $"\n\n{enrichedContext}";
|
promptText += $"\n\n{enrichedContext}";
|
||||||
promptText += @"
|
promptText += @"
|
||||||
|
|
||||||
Using the database context above, populate these fields in your JSON response:
|
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.
|
- 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.
|
- 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.";
|
- For each line item, set category to the best matching category from the EXISTING CATEGORIES list.";
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to get enriched context for Ollama, proceeding without it");
|
_logger.LogWarning(ex, "Failed to get enriched context for Ollama, proceeding without it");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
promptText += "\n\nRespond ONLY with valid JSON, no other text.";
|
promptText += "\n\nRespond ONLY with valid JSON, no other text.";
|
||||||
return promptText;
|
return promptText;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ParsedReceiptData ParseResponse(string? content)
|
private static ParsedReceiptData ParseResponse(string? content)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(content))
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
return new ParsedReceiptData();
|
return new ParsedReceiptData();
|
||||||
|
|
||||||
return JsonSerializer.Deserialize<ParsedReceiptData>(content, new JsonSerializerOptions
|
return JsonSerializer.Deserialize<ParsedReceiptData>(content, new JsonSerializerOptions
|
||||||
{
|
{
|
||||||
PropertyNameCaseInsensitive = true
|
PropertyNameCaseInsensitive = true
|
||||||
}) ?? new ParsedReceiptData();
|
}) ?? new ParsedReceiptData();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ApplyParseResultAsync(Receipt receipt, long receiptId, ParsedReceiptData parseData, string? notes)
|
private async Task ApplyParseResultAsync(Receipt receipt, long receiptId, ParsedReceiptData parseData, string? notes)
|
||||||
{
|
{
|
||||||
// Update receipt fields
|
// Update receipt fields
|
||||||
receipt.ParsingNotes = notes;
|
receipt.ParsingNotes = notes;
|
||||||
receipt.Merchant = parseData.Merchant;
|
receipt.Merchant = parseData.Merchant;
|
||||||
receipt.Total = parseData.Total;
|
receipt.Total = parseData.Total;
|
||||||
receipt.Subtotal = parseData.Subtotal;
|
receipt.Subtotal = parseData.Subtotal;
|
||||||
receipt.Tax = parseData.Tax;
|
receipt.Tax = parseData.Tax;
|
||||||
receipt.ReceiptDate = parseData.ReceiptDate;
|
receipt.ReceiptDate = parseData.ReceiptDate;
|
||||||
receipt.DueDate = parseData.DueDate;
|
receipt.DueDate = parseData.DueDate;
|
||||||
|
|
||||||
// Update transaction merchant if needed
|
// Update transaction merchant if needed
|
||||||
if (receipt.Transaction != null &&
|
if (receipt.Transaction != null &&
|
||||||
!string.IsNullOrWhiteSpace(parseData.Merchant) &&
|
!string.IsNullOrWhiteSpace(parseData.Merchant) &&
|
||||||
receipt.Transaction.MerchantId == null)
|
receipt.Transaction.MerchantId == null)
|
||||||
{
|
{
|
||||||
var merchantId = await _merchantService.GetOrCreateIdAsync(parseData.Merchant);
|
var merchantId = await _merchantService.GetOrCreateIdAsync(parseData.Merchant);
|
||||||
receipt.Transaction.MerchantId = merchantId;
|
receipt.Transaction.MerchantId = merchantId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update transaction category if AI suggested one and the transaction has no category
|
// Update transaction category if AI suggested one and the transaction has no category
|
||||||
if (receipt.Transaction != null &&
|
if (receipt.Transaction != null &&
|
||||||
!string.IsNullOrWhiteSpace(parseData.SuggestedCategory) &&
|
!string.IsNullOrWhiteSpace(parseData.SuggestedCategory) &&
|
||||||
string.IsNullOrWhiteSpace(receipt.Transaction.Category))
|
string.IsNullOrWhiteSpace(receipt.Transaction.Category))
|
||||||
{
|
{
|
||||||
receipt.Transaction.Category = parseData.SuggestedCategory;
|
receipt.Transaction.Category = parseData.SuggestedCategory;
|
||||||
_logger.LogInformation("Set transaction {TransactionId} category to '{Category}' from AI suggestion",
|
_logger.LogInformation("Set transaction {TransactionId} category to '{Category}' from AI suggestion",
|
||||||
receipt.Transaction.Id, parseData.SuggestedCategory);
|
receipt.Transaction.Id, parseData.SuggestedCategory);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace line items
|
// Replace line items
|
||||||
var existingItems = await _db.ReceiptLineItems
|
var existingItems = await _db.ReceiptLineItems
|
||||||
.Where(li => li.ReceiptId == receiptId)
|
.Where(li => li.ReceiptId == receiptId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
_db.ReceiptLineItems.RemoveRange(existingItems);
|
_db.ReceiptLineItems.RemoveRange(existingItems);
|
||||||
|
|
||||||
var lineItems = parseData.LineItems.Select((item, index) => new ReceiptLineItem
|
var lineItems = parseData.LineItems.Select((item, index) => new ReceiptLineItem
|
||||||
{
|
{
|
||||||
ReceiptId = receiptId,
|
ReceiptId = receiptId,
|
||||||
LineNumber = index + 1,
|
LineNumber = index + 1,
|
||||||
Description = item.Description,
|
Description = item.Description,
|
||||||
Sku = item.Upc,
|
Sku = item.Upc,
|
||||||
Quantity = item.Quantity,
|
Quantity = item.Quantity,
|
||||||
UnitPrice = item.UnitPrice,
|
UnitPrice = item.UnitPrice,
|
||||||
LineTotal = item.LineTotal,
|
LineTotal = item.LineTotal,
|
||||||
Category = item.Category,
|
Category = item.Category,
|
||||||
Voided = item.Voided
|
Voided = item.Voided
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
_db.ReceiptLineItems.AddRange(lineItems);
|
_db.ReceiptLineItems.AddRange(lineItems);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SaveParseLogAsync(ReceiptParseLog parseLog, string? error = null)
|
private async Task SaveParseLogAsync(ReceiptParseLog parseLog, string? error = null)
|
||||||
{
|
{
|
||||||
parseLog.Error = error;
|
parseLog.Error = error;
|
||||||
parseLog.CompletedAtUtc = DateTime.UtcNow;
|
parseLog.CompletedAtUtc = DateTime.UtcNow;
|
||||||
_db.ReceiptParseLogs.Add(parseLog);
|
_db.ReceiptParseLogs.Add(parseLog);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task TryAutoMapReceiptAsync(Receipt receipt, long receiptId, long? suggestedTransactionId)
|
private async Task TryAutoMapReceiptAsync(Receipt receipt, long receiptId, long? suggestedTransactionId)
|
||||||
{
|
{
|
||||||
// If AI suggested a specific transaction, try mapping directly
|
// If AI suggested a specific transaction, try mapping directly
|
||||||
if (!receipt.TransactionId.HasValue && suggestedTransactionId.HasValue)
|
if (!receipt.TransactionId.HasValue && suggestedTransactionId.HasValue)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var transaction = await _db.Transactions.FindAsync(suggestedTransactionId.Value);
|
var transaction = await _db.Transactions.FindAsync(suggestedTransactionId.Value);
|
||||||
if (transaction != null)
|
if (transaction != null)
|
||||||
{
|
{
|
||||||
// Verify the transaction isn't already mapped to another receipt
|
// Verify the transaction isn't already mapped to another receipt
|
||||||
var alreadyMapped = await _db.Receipts
|
var alreadyMapped = await _db.Receipts
|
||||||
.AnyAsync(r => r.TransactionId == suggestedTransactionId.Value && r.Id != receiptId);
|
.AnyAsync(r => r.TransactionId == suggestedTransactionId.Value && r.Id != receiptId);
|
||||||
|
|
||||||
if (!alreadyMapped)
|
if (!alreadyMapped)
|
||||||
{
|
{
|
||||||
var success = await _receiptManager.MapReceiptToTransactionAsync(receiptId, suggestedTransactionId.Value);
|
var success = await _receiptManager.MapReceiptToTransactionAsync(receiptId, suggestedTransactionId.Value);
|
||||||
if (success)
|
if (success)
|
||||||
{
|
{
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"AI-suggested mapping: receipt {ReceiptId} → transaction {TransactionId}",
|
"AI-suggested mapping: receipt {ReceiptId} → transaction {TransactionId}",
|
||||||
receiptId, suggestedTransactionId.Value);
|
receiptId, suggestedTransactionId.Value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "AI-suggested mapping failed for receipt {ReceiptId} → transaction {TransactionId}",
|
_logger.LogWarning(ex, "AI-suggested mapping failed for receipt {ReceiptId} → transaction {TransactionId}",
|
||||||
receiptId, suggestedTransactionId.Value);
|
receiptId, suggestedTransactionId.Value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to the existing auto-mapper
|
// Fall back to the existing auto-mapper
|
||||||
if (receipt.TransactionId.HasValue)
|
if (receipt.TransactionId.HasValue)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var scope = _serviceProvider.CreateScope();
|
using var scope = _serviceProvider.CreateScope();
|
||||||
var autoMapper = scope.ServiceProvider.GetRequiredService<IReceiptAutoMapper>();
|
var autoMapper = scope.ServiceProvider.GetRequiredService<IReceiptAutoMapper>();
|
||||||
await autoMapper.AutoMapReceiptAsync(receiptId);
|
await autoMapper.AutoMapReceiptAsync(receiptId);
|
||||||
_logger.LogInformation("Auto-mapping completed for receipt {ReceiptId}", receiptId);
|
_logger.LogInformation("Auto-mapping completed for receipt {ReceiptId}", receiptId);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Auto-mapping failed for receipt {ReceiptId}: {Message}", receiptId, ex.Message);
|
_logger.LogWarning(ex, "Auto-mapping failed for receipt {ReceiptId}: {Message}", receiptId, ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> LoadPromptTemplateAsync()
|
private async Task<string> LoadPromptTemplateAsync()
|
||||||
{
|
{
|
||||||
if (_promptTemplate != null)
|
if (_promptTemplate != null)
|
||||||
return _promptTemplate;
|
return _promptTemplate;
|
||||||
|
|
||||||
var promptPath = Path.Combine(AppContext.BaseDirectory, "Prompts", "ReceiptParserPrompt.txt");
|
var promptPath = Path.Combine(AppContext.BaseDirectory, "Prompts", "ReceiptParserPrompt.txt");
|
||||||
|
|
||||||
if (!File.Exists(promptPath))
|
if (!File.Exists(promptPath))
|
||||||
throw new FileNotFoundException($"Receipt parser prompt template not found at: {promptPath}");
|
throw new FileNotFoundException($"Receipt parser prompt template not found at: {promptPath}");
|
||||||
|
|
||||||
_promptTemplate = await File.ReadAllTextAsync(promptPath);
|
_promptTemplate = await File.ReadAllTextAsync(promptPath);
|
||||||
return _promptTemplate;
|
return _promptTemplate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves the appropriate AI vision client based on model name.
|
/// Resolves the appropriate AI vision client based on model name.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IAIVisionClientResolver
|
public interface IAIVisionClientResolver
|
||||||
{
|
{
|
||||||
(IAIVisionClient Client, string Provider) Resolve(string model);
|
(IAIVisionClient Client, string Provider) Resolve(string model);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AIVisionClientResolver : IAIVisionClientResolver
|
public class AIVisionClientResolver : IAIVisionClientResolver
|
||||||
{
|
{
|
||||||
private readonly OpenAIVisionClient _openAIClient;
|
private readonly OpenAIVisionClient _openAIClient;
|
||||||
private readonly ClaudeVisionClient _claudeClient;
|
private readonly ClaudeVisionClient _claudeClient;
|
||||||
private readonly OllamaVisionClient _ollamaClient;
|
private readonly OllamaVisionClient _ollamaClient;
|
||||||
private readonly LlamaCppVisionClient _llamaCppClient;
|
private readonly LlamaCppVisionClient _llamaCppClient;
|
||||||
|
|
||||||
public AIVisionClientResolver(
|
public AIVisionClientResolver(
|
||||||
OpenAIVisionClient openAIClient,
|
OpenAIVisionClient openAIClient,
|
||||||
ClaudeVisionClient claudeClient,
|
ClaudeVisionClient claudeClient,
|
||||||
OllamaVisionClient ollamaClient,
|
OllamaVisionClient ollamaClient,
|
||||||
LlamaCppVisionClient llamaCppClient)
|
LlamaCppVisionClient llamaCppClient)
|
||||||
{
|
{
|
||||||
_openAIClient = openAIClient;
|
_openAIClient = openAIClient;
|
||||||
_claudeClient = claudeClient;
|
_claudeClient = claudeClient;
|
||||||
_ollamaClient = ollamaClient;
|
_ollamaClient = ollamaClient;
|
||||||
_llamaCppClient = llamaCppClient;
|
_llamaCppClient = llamaCppClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
public (IAIVisionClient Client, string Provider) Resolve(string model)
|
public (IAIVisionClient Client, string Provider) Resolve(string model)
|
||||||
{
|
{
|
||||||
if (model.StartsWith("llamacpp:"))
|
if (model.StartsWith("llamacpp:"))
|
||||||
return (_llamaCppClient, "LlamaCpp");
|
return (_llamaCppClient, "LlamaCpp");
|
||||||
|
|
||||||
if (model.StartsWith("ollama:"))
|
if (model.StartsWith("ollama:"))
|
||||||
return (_ollamaClient, "Ollama");
|
return (_ollamaClient, "Ollama");
|
||||||
|
|
||||||
if (model.StartsWith("claude-"))
|
if (model.StartsWith("claude-"))
|
||||||
return (_claudeClient, "Anthropic");
|
return (_claudeClient, "Anthropic");
|
||||||
|
|
||||||
return (_openAIClient, "OpenAI");
|
return (_openAIClient, "OpenAI");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ParsedReceiptData
|
public class ParsedReceiptData
|
||||||
{
|
{
|
||||||
public string? Merchant { get; set; }
|
public string? Merchant { get; set; }
|
||||||
public DateTime? ReceiptDate { get; set; }
|
public DateTime? ReceiptDate { get; set; }
|
||||||
public DateTime? DueDate { get; set; }
|
public DateTime? DueDate { get; set; }
|
||||||
public decimal? Subtotal { get; set; }
|
public decimal? Subtotal { get; set; }
|
||||||
public decimal? Tax { get; set; }
|
public decimal? Tax { get; set; }
|
||||||
public decimal? Total { get; set; }
|
public decimal? Total { get; set; }
|
||||||
public decimal Confidence { get; set; } = 0.5m;
|
public decimal Confidence { get; set; } = 0.5m;
|
||||||
public string? SuggestedCategory { get; set; }
|
public string? SuggestedCategory { get; set; }
|
||||||
[JsonConverter(typeof(NullableLongConverter))]
|
[JsonConverter(typeof(NullableLongConverter))]
|
||||||
public long? SuggestedTransactionId { get; set; }
|
public long? SuggestedTransactionId { get; set; }
|
||||||
public List<ParsedLineItem> LineItems { get; set; } = new();
|
public List<ParsedLineItem> LineItems { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ParsedLineItem
|
public class ParsedLineItem
|
||||||
{
|
{
|
||||||
public string Description { get; set; } = "";
|
public string Description { get; set; } = "";
|
||||||
public string? Upc { get; set; }
|
public string? Upc { get; set; }
|
||||||
public decimal? Quantity { get; set; }
|
public decimal? Quantity { get; set; }
|
||||||
public decimal? UnitPrice { get; set; }
|
public decimal? UnitPrice { get; set; }
|
||||||
public decimal LineTotal { get; set; }
|
public decimal LineTotal { get; set; }
|
||||||
public string? Category { get; set; }
|
public string? Category { get; set; }
|
||||||
public bool Voided { get; set; }
|
public bool Voided { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ReceiptParseResult
|
public class ReceiptParseResult
|
||||||
{
|
{
|
||||||
public bool IsSuccess { get; init; }
|
public bool IsSuccess { get; init; }
|
||||||
public string? Message { get; init; }
|
public string? Message { get; init; }
|
||||||
|
|
||||||
public static ReceiptParseResult Success(string message) =>
|
public static ReceiptParseResult Success(string message) =>
|
||||||
new() { IsSuccess = true, Message = message };
|
new() { IsSuccess = true, Message = message };
|
||||||
|
|
||||||
public static ReceiptParseResult Failure(string message) =>
|
public static ReceiptParseResult Failure(string message) =>
|
||||||
new() { IsSuccess = false, Message = message };
|
new() { IsSuccess = false, Message = message };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles AI responses that return suggestedTransactionId as a string ("null", "N/A", "123")
|
/// Handles AI responses that return suggestedTransactionId as a string ("null", "N/A", "123")
|
||||||
/// instead of as a JSON number or null.
|
/// instead of as a JSON number or null.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class NullableLongConverter : JsonConverter<long?>
|
public class NullableLongConverter : JsonConverter<long?>
|
||||||
{
|
{
|
||||||
public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||||
{
|
{
|
||||||
switch (reader.TokenType)
|
switch (reader.TokenType)
|
||||||
{
|
{
|
||||||
case JsonTokenType.Number:
|
case JsonTokenType.Number:
|
||||||
return reader.GetInt64();
|
return reader.GetInt64();
|
||||||
case JsonTokenType.String:
|
case JsonTokenType.String:
|
||||||
var str = reader.GetString();
|
var str = reader.GetString();
|
||||||
if (string.IsNullOrWhiteSpace(str) ||
|
if (string.IsNullOrWhiteSpace(str) ||
|
||||||
str.Equals("null", StringComparison.OrdinalIgnoreCase) ||
|
str.Equals("null", StringComparison.OrdinalIgnoreCase) ||
|
||||||
str.Equals("N/A", StringComparison.OrdinalIgnoreCase) ||
|
str.Equals("N/A", StringComparison.OrdinalIgnoreCase) ||
|
||||||
str.Equals("none", StringComparison.OrdinalIgnoreCase))
|
str.Equals("none", StringComparison.OrdinalIgnoreCase))
|
||||||
return null;
|
return null;
|
||||||
return long.TryParse(str, out var val) ? val : null;
|
return long.TryParse(str, out var val) ? val : null;
|
||||||
case JsonTokenType.Null:
|
case JsonTokenType.Null:
|
||||||
return null;
|
return null;
|
||||||
default:
|
default:
|
||||||
reader.Skip();
|
reader.Skip();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options)
|
public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options)
|
||||||
{
|
{
|
||||||
if (value.HasValue)
|
if (value.HasValue)
|
||||||
writer.WriteNumberValue(value.Value);
|
writer.WriteNumberValue(value.Value);
|
||||||
else
|
else
|
||||||
writer.WriteNullValue();
|
writer.WriteNullValue();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,159 +1,159 @@
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace MoneyMap.Services.AITools
|
namespace MoneyMap.Services.AITools
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Provider-agnostic tool definition for AI function calling.
|
/// Provider-agnostic tool definition for AI function calling.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AIToolDefinition
|
public class AIToolDefinition
|
||||||
{
|
{
|
||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
public string Description { get; set; } = "";
|
public string Description { get; set; } = "";
|
||||||
public List<AIToolParameter> Parameters { get; set; } = new();
|
public List<AIToolParameter> Parameters { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AIToolParameter
|
public class AIToolParameter
|
||||||
{
|
{
|
||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
public string Type { get; set; } = "string"; // string, number, integer
|
public string Type { get; set; } = "string"; // string, number, integer
|
||||||
public string Description { get; set; } = "";
|
public string Description { get; set; } = "";
|
||||||
public bool Required { get; set; }
|
public bool Required { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents a tool call from the AI model.
|
/// Represents a tool call from the AI model.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AIToolCall
|
public class AIToolCall
|
||||||
{
|
{
|
||||||
public string Id { get; set; } = "";
|
public string Id { get; set; } = "";
|
||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
public Dictionary<string, object?> Arguments { get; set; } = new();
|
public Dictionary<string, object?> Arguments { get; set; } = new();
|
||||||
|
|
||||||
public string? GetString(string key)
|
public string? GetString(string key)
|
||||||
{
|
{
|
||||||
if (Arguments.TryGetValue(key, out var val) && val != null)
|
if (Arguments.TryGetValue(key, out var val) && val != null)
|
||||||
return val.ToString();
|
return val.ToString();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public decimal? GetDecimal(string key)
|
public decimal? GetDecimal(string key)
|
||||||
{
|
{
|
||||||
if (Arguments.TryGetValue(key, out var val) && val != null)
|
if (Arguments.TryGetValue(key, out var val) && val != null)
|
||||||
{
|
{
|
||||||
if (decimal.TryParse(val.ToString(), out var d))
|
if (decimal.TryParse(val.ToString(), out var d))
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int? GetInt(string key)
|
public int? GetInt(string key)
|
||||||
{
|
{
|
||||||
if (Arguments.TryGetValue(key, out var val) && val != null)
|
if (Arguments.TryGetValue(key, out var val) && val != null)
|
||||||
{
|
{
|
||||||
if (int.TryParse(val.ToString(), out var i))
|
if (int.TryParse(val.ToString(), out var i))
|
||||||
return i;
|
return i;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Result of executing a tool, returned to the AI.
|
/// Result of executing a tool, returned to the AI.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AIToolResult
|
public class AIToolResult
|
||||||
{
|
{
|
||||||
public string ToolCallId { get; set; } = "";
|
public string ToolCallId { get; set; } = "";
|
||||||
public string Content { get; set; } = "";
|
public string Content { get; set; } = "";
|
||||||
public bool IsError { get; set; }
|
public bool IsError { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Static registry of all tools available to the receipt parsing AI.
|
/// Static registry of all tools available to the receipt parsing AI.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class AIToolRegistry
|
public static class AIToolRegistry
|
||||||
{
|
{
|
||||||
public static List<AIToolDefinition> GetAllTools() => new()
|
public static List<AIToolDefinition> GetAllTools() => new()
|
||||||
{
|
{
|
||||||
new AIToolDefinition
|
new AIToolDefinition
|
||||||
{
|
{
|
||||||
Name = "search_categories",
|
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.",
|
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()
|
Parameters = new()
|
||||||
{
|
{
|
||||||
new AIToolParameter
|
new AIToolParameter
|
||||||
{
|
{
|
||||||
Name = "query",
|
Name = "query",
|
||||||
Type = "string",
|
Type = "string",
|
||||||
Description = "Optional filter text to search category names (e.g., 'grocery', 'utility'). Omit to get all categories.",
|
Description = "Optional filter text to search category names (e.g., 'grocery', 'utility'). Omit to get all categories.",
|
||||||
Required = false
|
Required = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
new AIToolDefinition
|
new AIToolDefinition
|
||||||
{
|
{
|
||||||
Name = "search_transactions",
|
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.",
|
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()
|
Parameters = new()
|
||||||
{
|
{
|
||||||
new AIToolParameter
|
new AIToolParameter
|
||||||
{
|
{
|
||||||
Name = "merchant",
|
Name = "merchant",
|
||||||
Type = "string",
|
Type = "string",
|
||||||
Description = "Merchant or store name to search for (partial match)",
|
Description = "Merchant or store name to search for (partial match)",
|
||||||
Required = false
|
Required = false
|
||||||
},
|
},
|
||||||
new AIToolParameter
|
new AIToolParameter
|
||||||
{
|
{
|
||||||
Name = "minDate",
|
Name = "minDate",
|
||||||
Type = "string",
|
Type = "string",
|
||||||
Description = "Earliest transaction date (YYYY-MM-DD format)",
|
Description = "Earliest transaction date (YYYY-MM-DD format)",
|
||||||
Required = false
|
Required = false
|
||||||
},
|
},
|
||||||
new AIToolParameter
|
new AIToolParameter
|
||||||
{
|
{
|
||||||
Name = "maxDate",
|
Name = "maxDate",
|
||||||
Type = "string",
|
Type = "string",
|
||||||
Description = "Latest transaction date (YYYY-MM-DD format)",
|
Description = "Latest transaction date (YYYY-MM-DD format)",
|
||||||
Required = false
|
Required = false
|
||||||
},
|
},
|
||||||
new AIToolParameter
|
new AIToolParameter
|
||||||
{
|
{
|
||||||
Name = "minAmount",
|
Name = "minAmount",
|
||||||
Type = "number",
|
Type = "number",
|
||||||
Description = "Minimum absolute transaction amount",
|
Description = "Minimum absolute transaction amount",
|
||||||
Required = false
|
Required = false
|
||||||
},
|
},
|
||||||
new AIToolParameter
|
new AIToolParameter
|
||||||
{
|
{
|
||||||
Name = "maxAmount",
|
Name = "maxAmount",
|
||||||
Type = "number",
|
Type = "number",
|
||||||
Description = "Maximum absolute transaction amount",
|
Description = "Maximum absolute transaction amount",
|
||||||
Required = false
|
Required = false
|
||||||
},
|
},
|
||||||
new AIToolParameter
|
new AIToolParameter
|
||||||
{
|
{
|
||||||
Name = "limit",
|
Name = "limit",
|
||||||
Type = "integer",
|
Type = "integer",
|
||||||
Description = "Maximum results to return (default 10, max 20)",
|
Description = "Maximum results to return (default 10, max 20)",
|
||||||
Required = false
|
Required = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
new AIToolDefinition
|
new AIToolDefinition
|
||||||
{
|
{
|
||||||
Name = "search_merchants",
|
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.",
|
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()
|
Parameters = new()
|
||||||
{
|
{
|
||||||
new AIToolParameter
|
new AIToolParameter
|
||||||
{
|
{
|
||||||
Name = "query",
|
Name = "query",
|
||||||
Type = "string",
|
Type = "string",
|
||||||
Description = "Merchant name to search for (partial match)",
|
Description = "Merchant name to search for (partial match)",
|
||||||
Required = true
|
Required = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,280 +1,280 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using MoneyMap.Data;
|
using MoneyMap.Data;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace MoneyMap.Services.AITools
|
namespace MoneyMap.Services.AITools
|
||||||
{
|
{
|
||||||
public interface IAIToolExecutor
|
public interface IAIToolExecutor
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Execute a single tool call and return the result as JSON.
|
/// Execute a single tool call and return the result as JSON.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<AIToolResult> ExecuteAsync(AIToolCall toolCall);
|
Task<AIToolResult> ExecuteAsync(AIToolCall toolCall);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pre-fetch all relevant context as a text block for providers that don't support tool use (Ollama).
|
/// Pre-fetch all relevant context as a text block for providers that don't support tool use (Ollama).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<string> GetEnrichedContextAsync(DateTime? receiptDate = null, decimal? total = null, string? merchantHint = null);
|
Task<string> GetEnrichedContextAsync(DateTime? receiptDate = null, decimal? total = null, string? merchantHint = null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AIToolExecutor : IAIToolExecutor
|
public class AIToolExecutor : IAIToolExecutor
|
||||||
{
|
{
|
||||||
private readonly MoneyMapContext _db;
|
private readonly MoneyMapContext _db;
|
||||||
private readonly ILogger<AIToolExecutor> _logger;
|
private readonly ILogger<AIToolExecutor> _logger;
|
||||||
private const int MaxResults = 20;
|
private const int MaxResults = 20;
|
||||||
|
|
||||||
public AIToolExecutor(MoneyMapContext db, ILogger<AIToolExecutor> logger)
|
public AIToolExecutor(MoneyMapContext db, ILogger<AIToolExecutor> logger)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AIToolResult> ExecuteAsync(AIToolCall toolCall)
|
public async Task<AIToolResult> ExecuteAsync(AIToolCall toolCall)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Executing AI tool: {ToolName} with args: {Args}",
|
_logger.LogInformation("Executing AI tool: {ToolName} with args: {Args}",
|
||||||
toolCall.Name, JsonSerializer.Serialize(toolCall.Arguments));
|
toolCall.Name, JsonSerializer.Serialize(toolCall.Arguments));
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = toolCall.Name switch
|
var result = toolCall.Name switch
|
||||||
{
|
{
|
||||||
"search_categories" => await SearchCategoriesAsync(toolCall),
|
"search_categories" => await SearchCategoriesAsync(toolCall),
|
||||||
"search_transactions" => await SearchTransactionsAsync(toolCall),
|
"search_transactions" => await SearchTransactionsAsync(toolCall),
|
||||||
"search_merchants" => await SearchMerchantsAsync(toolCall),
|
"search_merchants" => await SearchMerchantsAsync(toolCall),
|
||||||
_ => $"{{\"error\": \"Unknown tool: {toolCall.Name}\"}}"
|
_ => $"{{\"error\": \"Unknown tool: {toolCall.Name}\"}}"
|
||||||
};
|
};
|
||||||
|
|
||||||
_logger.LogInformation("Tool {ToolName} returned {Length} chars", toolCall.Name, result.Length);
|
_logger.LogInformation("Tool {ToolName} returned {Length} chars", toolCall.Name, result.Length);
|
||||||
|
|
||||||
return new AIToolResult
|
return new AIToolResult
|
||||||
{
|
{
|
||||||
ToolCallId = toolCall.Id,
|
ToolCallId = toolCall.Id,
|
||||||
Content = result
|
Content = result
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error executing tool {ToolName}", toolCall.Name);
|
_logger.LogError(ex, "Error executing tool {ToolName}", toolCall.Name);
|
||||||
return new AIToolResult
|
return new AIToolResult
|
||||||
{
|
{
|
||||||
ToolCallId = toolCall.Id,
|
ToolCallId = toolCall.Id,
|
||||||
Content = JsonSerializer.Serialize(new { error = ex.Message }),
|
Content = JsonSerializer.Serialize(new { error = ex.Message }),
|
||||||
IsError = true
|
IsError = true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> GetEnrichedContextAsync(DateTime? receiptDate, decimal? total, string? merchantHint)
|
public async Task<string> GetEnrichedContextAsync(DateTime? receiptDate, decimal? total, string? merchantHint)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
sb.AppendLine("=== DATABASE CONTEXT (use this to match categories and transactions) ===");
|
sb.AppendLine("=== DATABASE CONTEXT (use this to match categories and transactions) ===");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
|
|
||||||
// Categories
|
// Categories
|
||||||
var categories = await _db.CategoryMappings
|
var categories = await _db.CategoryMappings
|
||||||
.Include(cm => cm.Merchant)
|
.Include(cm => cm.Merchant)
|
||||||
.OrderBy(cm => cm.Category)
|
.OrderBy(cm => cm.Category)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var grouped = categories.GroupBy(c => c.Category).ToList();
|
var grouped = categories.GroupBy(c => c.Category).ToList();
|
||||||
sb.AppendLine($"EXISTING CATEGORIES ({grouped.Count} total):");
|
sb.AppendLine($"EXISTING CATEGORIES ({grouped.Count} total):");
|
||||||
foreach (var group in grouped)
|
foreach (var group in grouped)
|
||||||
{
|
{
|
||||||
var patterns = group.Select(c => c.Pattern).Take(5);
|
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);
|
var merchants = group.Where(c => c.Merchant != null).Select(c => c.Merchant!.Name).Distinct().Take(3);
|
||||||
sb.Append($" - {group.Key}: patterns=[{string.Join(", ", patterns)}]");
|
sb.Append($" - {group.Key}: patterns=[{string.Join(", ", patterns)}]");
|
||||||
if (merchants.Any())
|
if (merchants.Any())
|
||||||
sb.Append($", merchants=[{string.Join(", ", merchants)}]");
|
sb.Append($", merchants=[{string.Join(", ", merchants)}]");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
}
|
}
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
|
|
||||||
// Merchants matching hint
|
// Merchants matching hint
|
||||||
if (!string.IsNullOrWhiteSpace(merchantHint))
|
if (!string.IsNullOrWhiteSpace(merchantHint))
|
||||||
{
|
{
|
||||||
var matchingMerchants = await _db.Merchants
|
var matchingMerchants = await _db.Merchants
|
||||||
.Where(m => m.Name.Contains(merchantHint))
|
.Where(m => m.Name.Contains(merchantHint))
|
||||||
.Select(m => new
|
.Select(m => new
|
||||||
{
|
{
|
||||||
m.Name,
|
m.Name,
|
||||||
TransactionCount = m.Transactions.Count,
|
TransactionCount = m.Transactions.Count,
|
||||||
TopCategory = m.Transactions
|
TopCategory = m.Transactions
|
||||||
.Where(t => t.Category != "")
|
.Where(t => t.Category != "")
|
||||||
.GroupBy(t => t.Category)
|
.GroupBy(t => t.Category)
|
||||||
.OrderByDescending(g => g.Count())
|
.OrderByDescending(g => g.Count())
|
||||||
.Select(g => g.Key)
|
.Select(g => g.Key)
|
||||||
.FirstOrDefault()
|
.FirstOrDefault()
|
||||||
})
|
})
|
||||||
.Take(10)
|
.Take(10)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
if (matchingMerchants.Count > 0)
|
if (matchingMerchants.Count > 0)
|
||||||
{
|
{
|
||||||
sb.AppendLine($"MATCHING MERCHANTS for \"{merchantHint}\":");
|
sb.AppendLine($"MATCHING MERCHANTS for \"{merchantHint}\":");
|
||||||
foreach (var m in matchingMerchants)
|
foreach (var m in matchingMerchants)
|
||||||
sb.AppendLine($" - {m.Name} ({m.TransactionCount} transactions, typical category: {m.TopCategory ?? "none"})");
|
sb.AppendLine($" - {m.Name} ({m.TransactionCount} transactions, typical category: {m.TopCategory ?? "none"})");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matching transactions
|
// Matching transactions
|
||||||
if (receiptDate.HasValue || total.HasValue)
|
if (receiptDate.HasValue || total.HasValue)
|
||||||
{
|
{
|
||||||
var txQuery = _db.Transactions
|
var txQuery = _db.Transactions
|
||||||
.Include(t => t.Merchant)
|
.Include(t => t.Merchant)
|
||||||
.Where(t => !_db.Receipts.Any(r => r.TransactionId == t.Id))
|
.Where(t => !_db.Receipts.Any(r => r.TransactionId == t.Id))
|
||||||
.AsQueryable();
|
.AsQueryable();
|
||||||
|
|
||||||
if (receiptDate.HasValue)
|
if (receiptDate.HasValue)
|
||||||
{
|
{
|
||||||
var minDate = receiptDate.Value.AddDays(-1);
|
var minDate = receiptDate.Value.AddDays(-1);
|
||||||
var maxDate = receiptDate.Value.AddDays(7);
|
var maxDate = receiptDate.Value.AddDays(7);
|
||||||
txQuery = txQuery.Where(t => t.Date >= minDate && t.Date <= maxDate);
|
txQuery = txQuery.Where(t => t.Date >= minDate && t.Date <= maxDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (total.HasValue)
|
if (total.HasValue)
|
||||||
{
|
{
|
||||||
var absTotal = Math.Abs(total.Value);
|
var absTotal = Math.Abs(total.Value);
|
||||||
var minAmt = absTotal * 0.9m;
|
var minAmt = absTotal * 0.9m;
|
||||||
var maxAmt = absTotal * 1.1m;
|
var maxAmt = absTotal * 1.1m;
|
||||||
txQuery = txQuery.Where(t =>
|
txQuery = txQuery.Where(t =>
|
||||||
(t.Amount >= -maxAmt && t.Amount <= -minAmt) ||
|
(t.Amount >= -maxAmt && t.Amount <= -minAmt) ||
|
||||||
(t.Amount >= minAmt && t.Amount <= maxAmt));
|
(t.Amount >= minAmt && t.Amount <= maxAmt));
|
||||||
}
|
}
|
||||||
|
|
||||||
var transactions = await txQuery
|
var transactions = await txQuery
|
||||||
.OrderBy(t => t.Date)
|
.OrderBy(t => t.Date)
|
||||||
.Take(10)
|
.Take(10)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
if (transactions.Count > 0)
|
if (transactions.Count > 0)
|
||||||
{
|
{
|
||||||
sb.AppendLine("CANDIDATE TRANSACTIONS (unmapped, matching date/amount):");
|
sb.AppendLine("CANDIDATE TRANSACTIONS (unmapped, matching date/amount):");
|
||||||
foreach (var t in transactions)
|
foreach (var t in transactions)
|
||||||
{
|
{
|
||||||
sb.AppendLine($" - ID={t.Id}, Date={t.Date:yyyy-MM-dd}, Amount={t.Amount:C}, Name=\"{t.Name}\", " +
|
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}");
|
$"Merchant={t.Merchant?.Name ?? "none"}, Category={t.Category}");
|
||||||
}
|
}
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.AppendLine("=== END DATABASE CONTEXT ===");
|
sb.AppendLine("=== END DATABASE CONTEXT ===");
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> SearchCategoriesAsync(AIToolCall toolCall)
|
private async Task<string> SearchCategoriesAsync(AIToolCall toolCall)
|
||||||
{
|
{
|
||||||
var query = toolCall.GetString("query");
|
var query = toolCall.GetString("query");
|
||||||
|
|
||||||
var mappings = _db.CategoryMappings
|
var mappings = _db.CategoryMappings
|
||||||
.Include(cm => cm.Merchant)
|
.Include(cm => cm.Merchant)
|
||||||
.AsQueryable();
|
.AsQueryable();
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(query))
|
if (!string.IsNullOrWhiteSpace(query))
|
||||||
mappings = mappings.Where(cm => cm.Category.Contains(query));
|
mappings = mappings.Where(cm => cm.Category.Contains(query));
|
||||||
|
|
||||||
var results = await mappings
|
var results = await mappings
|
||||||
.OrderBy(cm => cm.Category)
|
.OrderBy(cm => cm.Category)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var grouped = results
|
var grouped = results
|
||||||
.GroupBy(c => c.Category)
|
.GroupBy(c => c.Category)
|
||||||
.Take(MaxResults)
|
.Take(MaxResults)
|
||||||
.Select(g => new
|
.Select(g => new
|
||||||
{
|
{
|
||||||
category = g.Key,
|
category = g.Key,
|
||||||
patterns = g.Select(c => c.Pattern).Take(5).ToList(),
|
patterns = g.Select(c => c.Pattern).Take(5).ToList(),
|
||||||
merchants = g.Where(c => c.Merchant != null)
|
merchants = g.Where(c => c.Merchant != null)
|
||||||
.Select(c => c.Merchant!.Name)
|
.Select(c => c.Merchant!.Name)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.Take(5)
|
.Take(5)
|
||||||
.ToList()
|
.ToList()
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return JsonSerializer.Serialize(new { categories = grouped });
|
return JsonSerializer.Serialize(new { categories = grouped });
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> SearchTransactionsAsync(AIToolCall toolCall)
|
private async Task<string> SearchTransactionsAsync(AIToolCall toolCall)
|
||||||
{
|
{
|
||||||
var merchant = toolCall.GetString("merchant");
|
var merchant = toolCall.GetString("merchant");
|
||||||
var minDateStr = toolCall.GetString("minDate");
|
var minDateStr = toolCall.GetString("minDate");
|
||||||
var maxDateStr = toolCall.GetString("maxDate");
|
var maxDateStr = toolCall.GetString("maxDate");
|
||||||
var minAmount = toolCall.GetDecimal("minAmount");
|
var minAmount = toolCall.GetDecimal("minAmount");
|
||||||
var maxAmount = toolCall.GetDecimal("maxAmount");
|
var maxAmount = toolCall.GetDecimal("maxAmount");
|
||||||
var limit = toolCall.GetInt("limit") ?? 10;
|
var limit = toolCall.GetInt("limit") ?? 10;
|
||||||
limit = Math.Min(limit, MaxResults);
|
limit = Math.Min(limit, MaxResults);
|
||||||
|
|
||||||
var txQuery = _db.Transactions
|
var txQuery = _db.Transactions
|
||||||
.Include(t => t.Merchant)
|
.Include(t => t.Merchant)
|
||||||
.Where(t => !_db.Receipts.Any(r => r.TransactionId == t.Id))
|
.Where(t => !_db.Receipts.Any(r => r.TransactionId == t.Id))
|
||||||
.AsQueryable();
|
.AsQueryable();
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(merchant))
|
if (!string.IsNullOrWhiteSpace(merchant))
|
||||||
{
|
{
|
||||||
txQuery = txQuery.Where(t =>
|
txQuery = txQuery.Where(t =>
|
||||||
t.Name.Contains(merchant) ||
|
t.Name.Contains(merchant) ||
|
||||||
(t.Merchant != null && t.Merchant.Name.Contains(merchant)));
|
(t.Merchant != null && t.Merchant.Name.Contains(merchant)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (DateTime.TryParse(minDateStr, out var minDate))
|
if (DateTime.TryParse(minDateStr, out var minDate))
|
||||||
txQuery = txQuery.Where(t => t.Date >= minDate);
|
txQuery = txQuery.Where(t => t.Date >= minDate);
|
||||||
|
|
||||||
if (DateTime.TryParse(maxDateStr, out var maxDate))
|
if (DateTime.TryParse(maxDateStr, out var maxDate))
|
||||||
txQuery = txQuery.Where(t => t.Date <= maxDate);
|
txQuery = txQuery.Where(t => t.Date <= maxDate);
|
||||||
|
|
||||||
if (minAmount.HasValue)
|
if (minAmount.HasValue)
|
||||||
{
|
{
|
||||||
var min = minAmount.Value;
|
var min = minAmount.Value;
|
||||||
txQuery = txQuery.Where(t => t.Amount <= -min || t.Amount >= min);
|
txQuery = txQuery.Where(t => t.Amount <= -min || t.Amount >= min);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maxAmount.HasValue)
|
if (maxAmount.HasValue)
|
||||||
{
|
{
|
||||||
var max = maxAmount.Value;
|
var max = maxAmount.Value;
|
||||||
txQuery = txQuery.Where(t => t.Amount >= -max && t.Amount <= max);
|
txQuery = txQuery.Where(t => t.Amount >= -max && t.Amount <= max);
|
||||||
}
|
}
|
||||||
|
|
||||||
var transactions = await txQuery
|
var transactions = await txQuery
|
||||||
.OrderByDescending(t => t.Date)
|
.OrderByDescending(t => t.Date)
|
||||||
.Take(limit)
|
.Take(limit)
|
||||||
.Select(t => new
|
.Select(t => new
|
||||||
{
|
{
|
||||||
id = t.Id,
|
id = t.Id,
|
||||||
date = t.Date.ToString("yyyy-MM-dd"),
|
date = t.Date.ToString("yyyy-MM-dd"),
|
||||||
amount = t.Amount,
|
amount = t.Amount,
|
||||||
name = t.Name,
|
name = t.Name,
|
||||||
merchant = t.Merchant != null ? t.Merchant.Name : null,
|
merchant = t.Merchant != null ? t.Merchant.Name : null,
|
||||||
category = t.Category
|
category = t.Category
|
||||||
})
|
})
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return JsonSerializer.Serialize(new { transactions });
|
return JsonSerializer.Serialize(new { transactions });
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> SearchMerchantsAsync(AIToolCall toolCall)
|
private async Task<string> SearchMerchantsAsync(AIToolCall toolCall)
|
||||||
{
|
{
|
||||||
var query = toolCall.GetString("query") ?? "";
|
var query = toolCall.GetString("query") ?? "";
|
||||||
|
|
||||||
var merchants = await _db.Merchants
|
var merchants = await _db.Merchants
|
||||||
.Where(m => m.Name.Contains(query))
|
.Where(m => m.Name.Contains(query))
|
||||||
.Select(m => new
|
.Select(m => new
|
||||||
{
|
{
|
||||||
name = m.Name,
|
name = m.Name,
|
||||||
transactionCount = m.Transactions.Count,
|
transactionCount = m.Transactions.Count,
|
||||||
topCategory = m.Transactions
|
topCategory = m.Transactions
|
||||||
.Where(t => t.Category != "")
|
.Where(t => t.Category != "")
|
||||||
.GroupBy(t => t.Category)
|
.GroupBy(t => t.Category)
|
||||||
.OrderByDescending(g => g.Count())
|
.OrderByDescending(g => g.Count())
|
||||||
.Select(g => g.Key)
|
.Select(g => g.Key)
|
||||||
.FirstOrDefault()
|
.FirstOrDefault()
|
||||||
})
|
})
|
||||||
.Take(MaxResults)
|
.Take(MaxResults)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return JsonSerializer.Serialize(new { merchants });
|
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 Microsoft.EntityFrameworkCore;
|
||||||
using MoneyMap.Data;
|
using MoneyMap.Data;
|
||||||
using MoneyMap.Models;
|
using MoneyMap.Models;
|
||||||
|
|
||||||
namespace MoneyMap.Services;
|
namespace MoneyMap.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Service for account management including retrieval, validation, and deletion.
|
/// Service for account management including retrieval, validation, and deletion.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IAccountService
|
public interface IAccountService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets an account by ID with optional related data.
|
/// Gets an account by ID with optional related data.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Account?> GetAccountByIdAsync(int id, bool includeRelated = false);
|
Task<Account?> GetAccountByIdAsync(int id, bool includeRelated = false);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets all accounts with optional statistics.
|
/// Gets all accounts with optional statistics.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<AccountWithStats>> GetAllAccountsWithStatsAsync();
|
Task<List<AccountWithStats>> GetAllAccountsWithStatsAsync();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets account details with cards and transaction count.
|
/// Gets account details with cards and transaction count.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<AccountDetails?> GetAccountDetailsAsync(int id);
|
Task<AccountDetails?> GetAccountDetailsAsync(int id);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks if an account can be deleted (no transactions exist).
|
/// Checks if an account can be deleted (no transactions exist).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<DeleteValidationResult> CanDeleteAccountAsync(int id);
|
Task<DeleteValidationResult> CanDeleteAccountAsync(int id);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deletes an account if it has no associated transactions.
|
/// Deletes an account if it has no associated transactions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<DeleteResult> DeleteAccountAsync(int id);
|
Task<DeleteResult> DeleteAccountAsync(int id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AccountService : IAccountService
|
public class AccountService : IAccountService
|
||||||
{
|
{
|
||||||
private readonly MoneyMapContext _db;
|
private readonly MoneyMapContext _db;
|
||||||
|
|
||||||
public AccountService(MoneyMapContext db)
|
public AccountService(MoneyMapContext db)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Account?> GetAccountByIdAsync(int id, bool includeRelated = false)
|
public async Task<Account?> GetAccountByIdAsync(int id, bool includeRelated = false)
|
||||||
{
|
{
|
||||||
var query = _db.Accounts.AsQueryable();
|
var query = _db.Accounts.AsQueryable();
|
||||||
|
|
||||||
if (includeRelated)
|
if (includeRelated)
|
||||||
{
|
{
|
||||||
query = query
|
query = query
|
||||||
.Include(a => a.Cards)
|
.Include(a => a.Cards)
|
||||||
.Include(a => a.Transactions);
|
.Include(a => a.Transactions);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await query.FirstOrDefaultAsync(a => a.Id == id);
|
return await query.FirstOrDefaultAsync(a => a.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<AccountWithStats>> GetAllAccountsWithStatsAsync()
|
public async Task<List<AccountWithStats>> GetAllAccountsWithStatsAsync()
|
||||||
{
|
{
|
||||||
var accounts = await _db.Accounts
|
var accounts = await _db.Accounts
|
||||||
.Include(a => a.Transactions)
|
.Include(a => a.Transactions)
|
||||||
.OrderBy(a => a.Owner)
|
.OrderBy(a => a.Owner)
|
||||||
.ThenBy(a => a.Institution)
|
.ThenBy(a => a.Institution)
|
||||||
.ThenBy(a => a.Last4)
|
.ThenBy(a => a.Last4)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return accounts.Select(a => new AccountWithStats
|
return accounts.Select(a => new AccountWithStats
|
||||||
{
|
{
|
||||||
Id = a.Id,
|
Id = a.Id,
|
||||||
Institution = a.Institution,
|
Institution = a.Institution,
|
||||||
AccountType = a.AccountType,
|
AccountType = a.AccountType,
|
||||||
Last4 = a.Last4,
|
Last4 = a.Last4,
|
||||||
Owner = a.Owner,
|
Owner = a.Owner,
|
||||||
Nickname = a.Nickname,
|
Nickname = a.Nickname,
|
||||||
TransactionCount = a.Transactions.Count
|
TransactionCount = a.Transactions.Count
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AccountDetails?> GetAccountDetailsAsync(int id)
|
public async Task<AccountDetails?> GetAccountDetailsAsync(int id)
|
||||||
{
|
{
|
||||||
var account = await _db.Accounts.FindAsync(id);
|
var account = await _db.Accounts.FindAsync(id);
|
||||||
if (account == null)
|
if (account == null)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
// Single query with projection to avoid N+1
|
// Single query with projection to avoid N+1
|
||||||
var cardStats = await _db.Cards
|
var cardStats = await _db.Cards
|
||||||
.Where(c => c.AccountId == id)
|
.Where(c => c.AccountId == id)
|
||||||
.OrderBy(c => c.Owner)
|
.OrderBy(c => c.Owner)
|
||||||
.ThenBy(c => c.Last4)
|
.ThenBy(c => c.Last4)
|
||||||
.Select(c => new CardWithStats
|
.Select(c => new CardWithStats
|
||||||
{
|
{
|
||||||
Card = c,
|
Card = c,
|
||||||
TransactionCount = c.Transactions.Count
|
TransactionCount = c.Transactions.Count
|
||||||
})
|
})
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
// Get transaction count for this account
|
// Get transaction count for this account
|
||||||
var accountTransactionCount = await _db.Transactions.CountAsync(t => t.AccountId == id);
|
var accountTransactionCount = await _db.Transactions.CountAsync(t => t.AccountId == id);
|
||||||
|
|
||||||
return new AccountDetails
|
return new AccountDetails
|
||||||
{
|
{
|
||||||
Account = account,
|
Account = account,
|
||||||
Cards = cardStats,
|
Cards = cardStats,
|
||||||
TransactionCount = accountTransactionCount
|
TransactionCount = accountTransactionCount
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<DeleteValidationResult> CanDeleteAccountAsync(int id)
|
public async Task<DeleteValidationResult> CanDeleteAccountAsync(int id)
|
||||||
{
|
{
|
||||||
var account = await _db.Accounts
|
var account = await _db.Accounts
|
||||||
.Include(a => a.Transactions)
|
.Include(a => a.Transactions)
|
||||||
.FirstOrDefaultAsync(a => a.Id == id);
|
.FirstOrDefaultAsync(a => a.Id == id);
|
||||||
|
|
||||||
if (account == null)
|
if (account == null)
|
||||||
return new DeleteValidationResult
|
return new DeleteValidationResult
|
||||||
{
|
{
|
||||||
CanDelete = false,
|
CanDelete = false,
|
||||||
Reason = "Account not found."
|
Reason = "Account not found."
|
||||||
};
|
};
|
||||||
|
|
||||||
if (account.Transactions.Any())
|
if (account.Transactions.Any())
|
||||||
return new DeleteValidationResult
|
return new DeleteValidationResult
|
||||||
{
|
{
|
||||||
CanDelete = false,
|
CanDelete = false,
|
||||||
Reason = $"Cannot delete account. It has {account.Transactions.Count} transaction(s) associated with it."
|
Reason = $"Cannot delete account. It has {account.Transactions.Count} transaction(s) associated with it."
|
||||||
};
|
};
|
||||||
|
|
||||||
return new DeleteValidationResult { CanDelete = true };
|
return new DeleteValidationResult { CanDelete = true };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<DeleteResult> DeleteAccountAsync(int id)
|
public async Task<DeleteResult> DeleteAccountAsync(int id)
|
||||||
{
|
{
|
||||||
var validation = await CanDeleteAccountAsync(id);
|
var validation = await CanDeleteAccountAsync(id);
|
||||||
if (!validation.CanDelete)
|
if (!validation.CanDelete)
|
||||||
{
|
{
|
||||||
return new DeleteResult
|
return new DeleteResult
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = validation.Reason ?? "Cannot delete account."
|
Message = validation.Reason ?? "Cannot delete account."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
var account = await _db.Accounts.FindAsync(id);
|
var account = await _db.Accounts.FindAsync(id);
|
||||||
if (account == null)
|
if (account == null)
|
||||||
{
|
{
|
||||||
return new DeleteResult
|
return new DeleteResult
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "Account not found."
|
Message = "Account not found."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
_db.Accounts.Remove(account);
|
_db.Accounts.Remove(account);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return new DeleteResult
|
return new DeleteResult
|
||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
Message = $"Deleted account {account.Institution} {account.Last4}"
|
Message = $"Deleted account {account.Institution} {account.Last4}"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DTOs
|
// DTOs
|
||||||
public class AccountWithStats
|
public class AccountWithStats
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Institution { get; set; } = "";
|
public string Institution { get; set; } = "";
|
||||||
public AccountType AccountType { get; set; }
|
public AccountType AccountType { get; set; }
|
||||||
public string Last4 { get; set; } = "";
|
public string Last4 { get; set; } = "";
|
||||||
public string Owner { get; set; } = "";
|
public string Owner { get; set; } = "";
|
||||||
public string? Nickname { get; set; }
|
public string? Nickname { get; set; }
|
||||||
public int TransactionCount { get; set; }
|
public int TransactionCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AccountDetails
|
public class AccountDetails
|
||||||
{
|
{
|
||||||
public Account Account { get; set; } = null!;
|
public Account Account { get; set; } = null!;
|
||||||
public List<CardWithStats> Cards { get; set; } = new();
|
public List<CardWithStats> Cards { get; set; } = new();
|
||||||
public int TransactionCount { get; set; }
|
public int TransactionCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CardWithStats
|
public class CardWithStats
|
||||||
{
|
{
|
||||||
public Card Card { get; set; } = null!;
|
public Card Card { get; set; } = null!;
|
||||||
public int TransactionCount { get; set; }
|
public int TransactionCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class DeleteValidationResult
|
public class DeleteValidationResult
|
||||||
{
|
{
|
||||||
public bool CanDelete { get; set; }
|
public bool CanDelete { get; set; }
|
||||||
public string? Reason { get; set; }
|
public string? Reason { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class DeleteResult
|
public class DeleteResult
|
||||||
{
|
{
|
||||||
public bool Success { get; set; }
|
public bool Success { get; set; }
|
||||||
public string Message { get; set; } = "";
|
public string Message { get; set; } = "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,347 +1,347 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using MoneyMap.Data;
|
using MoneyMap.Data;
|
||||||
using MoneyMap.Models;
|
using MoneyMap.Models;
|
||||||
|
|
||||||
namespace MoneyMap.Services;
|
namespace MoneyMap.Services;
|
||||||
|
|
||||||
public interface IBudgetService
|
public interface IBudgetService
|
||||||
{
|
{
|
||||||
// CRUD operations
|
// CRUD operations
|
||||||
Task<List<Budget>> GetAllBudgetsAsync(bool activeOnly = true);
|
Task<List<Budget>> GetAllBudgetsAsync(bool activeOnly = true);
|
||||||
Task<Budget?> GetBudgetByIdAsync(int id);
|
Task<Budget?> GetBudgetByIdAsync(int id);
|
||||||
Task<BudgetOperationResult> CreateBudgetAsync(Budget budget);
|
Task<BudgetOperationResult> CreateBudgetAsync(Budget budget);
|
||||||
Task<BudgetOperationResult> UpdateBudgetAsync(Budget budget);
|
Task<BudgetOperationResult> UpdateBudgetAsync(Budget budget);
|
||||||
Task<BudgetOperationResult> DeleteBudgetAsync(int id);
|
Task<BudgetOperationResult> DeleteBudgetAsync(int id);
|
||||||
|
|
||||||
// Budget status calculations
|
// Budget status calculations
|
||||||
Task<List<BudgetStatus>> GetAllBudgetStatusesAsync(DateTime? asOfDate = null);
|
Task<List<BudgetStatus>> GetAllBudgetStatusesAsync(DateTime? asOfDate = null);
|
||||||
Task<BudgetStatus?> GetBudgetStatusAsync(int budgetId, DateTime? asOfDate = null);
|
Task<BudgetStatus?> GetBudgetStatusAsync(int budgetId, DateTime? asOfDate = null);
|
||||||
|
|
||||||
// Helper methods
|
// Helper methods
|
||||||
Task<List<string>> GetAvailableCategoriesAsync();
|
Task<List<string>> GetAvailableCategoriesAsync();
|
||||||
(DateTime Start, DateTime End) GetPeriodBoundaries(BudgetPeriod period, DateTime startDate, DateTime asOfDate);
|
(DateTime Start, DateTime End) GetPeriodBoundaries(BudgetPeriod period, DateTime startDate, DateTime asOfDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class BudgetService : IBudgetService
|
public class BudgetService : IBudgetService
|
||||||
{
|
{
|
||||||
private readonly MoneyMapContext _db;
|
private readonly MoneyMapContext _db;
|
||||||
|
|
||||||
public BudgetService(MoneyMapContext db)
|
public BudgetService(MoneyMapContext db)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
#region CRUD Operations
|
#region CRUD Operations
|
||||||
|
|
||||||
public async Task<List<Budget>> GetAllBudgetsAsync(bool activeOnly = true)
|
public async Task<List<Budget>> GetAllBudgetsAsync(bool activeOnly = true)
|
||||||
{
|
{
|
||||||
var query = _db.Budgets.AsQueryable();
|
var query = _db.Budgets.AsQueryable();
|
||||||
|
|
||||||
if (activeOnly)
|
if (activeOnly)
|
||||||
query = query.Where(b => b.IsActive);
|
query = query.Where(b => b.IsActive);
|
||||||
|
|
||||||
return await query
|
return await query
|
||||||
.OrderBy(b => b.Category == null) // Total budget last
|
.OrderBy(b => b.Category == null) // Total budget last
|
||||||
.ThenBy(b => b.Category)
|
.ThenBy(b => b.Category)
|
||||||
.ThenBy(b => b.Period)
|
.ThenBy(b => b.Period)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Budget?> GetBudgetByIdAsync(int id)
|
public async Task<Budget?> GetBudgetByIdAsync(int id)
|
||||||
{
|
{
|
||||||
return await _db.Budgets.FindAsync(id);
|
return await _db.Budgets.FindAsync(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<BudgetOperationResult> CreateBudgetAsync(Budget budget)
|
public async Task<BudgetOperationResult> CreateBudgetAsync(Budget budget)
|
||||||
{
|
{
|
||||||
// Validate amount
|
// Validate amount
|
||||||
if (budget.Amount <= 0)
|
if (budget.Amount <= 0)
|
||||||
{
|
{
|
||||||
return new BudgetOperationResult
|
return new BudgetOperationResult
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "Budget amount must be greater than zero."
|
Message = "Budget amount must be greater than zero."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate active budget (same category + period)
|
// Check for duplicate active budget (same category + period)
|
||||||
var existing = await _db.Budgets
|
var existing = await _db.Budgets
|
||||||
.Where(b => b.IsActive && b.Category == budget.Category && b.Period == budget.Period)
|
.Where(b => b.IsActive && b.Category == budget.Category && b.Period == budget.Period)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
if (existing != null)
|
if (existing != null)
|
||||||
{
|
{
|
||||||
var categoryName = budget.Category ?? "Total Spending";
|
var categoryName = budget.Category ?? "Total Spending";
|
||||||
return new BudgetOperationResult
|
return new BudgetOperationResult
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = $"An active {budget.Period} budget for '{categoryName}' already exists."
|
Message = $"An active {budget.Period} budget for '{categoryName}' already exists."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
budget.IsActive = true;
|
budget.IsActive = true;
|
||||||
_db.Budgets.Add(budget);
|
_db.Budgets.Add(budget);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return new BudgetOperationResult
|
return new BudgetOperationResult
|
||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
Message = "Budget created successfully.",
|
Message = "Budget created successfully.",
|
||||||
BudgetId = budget.Id
|
BudgetId = budget.Id
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<BudgetOperationResult> UpdateBudgetAsync(Budget budget)
|
public async Task<BudgetOperationResult> UpdateBudgetAsync(Budget budget)
|
||||||
{
|
{
|
||||||
var existing = await _db.Budgets.FindAsync(budget.Id);
|
var existing = await _db.Budgets.FindAsync(budget.Id);
|
||||||
if (existing == null)
|
if (existing == null)
|
||||||
{
|
{
|
||||||
return new BudgetOperationResult
|
return new BudgetOperationResult
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "Budget not found."
|
Message = "Budget not found."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate amount
|
// Validate amount
|
||||||
if (budget.Amount <= 0)
|
if (budget.Amount <= 0)
|
||||||
{
|
{
|
||||||
return new BudgetOperationResult
|
return new BudgetOperationResult
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "Budget amount must be greater than zero."
|
Message = "Budget amount must be greater than zero."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate if category or period changed
|
// Check for duplicate if category or period changed
|
||||||
if (budget.IsActive && (existing.Category != budget.Category || existing.Period != budget.Period))
|
if (budget.IsActive && (existing.Category != budget.Category || existing.Period != budget.Period))
|
||||||
{
|
{
|
||||||
var duplicate = await _db.Budgets
|
var duplicate = await _db.Budgets
|
||||||
.Where(b => b.Id != budget.Id && b.IsActive && b.Category == budget.Category && b.Period == budget.Period)
|
.Where(b => b.Id != budget.Id && b.IsActive && b.Category == budget.Category && b.Period == budget.Period)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
if (duplicate != null)
|
if (duplicate != null)
|
||||||
{
|
{
|
||||||
var categoryName = budget.Category ?? "Total Spending";
|
var categoryName = budget.Category ?? "Total Spending";
|
||||||
return new BudgetOperationResult
|
return new BudgetOperationResult
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = $"An active {budget.Period} budget for '{categoryName}' already exists."
|
Message = $"An active {budget.Period} budget for '{categoryName}' already exists."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
existing.Category = budget.Category;
|
existing.Category = budget.Category;
|
||||||
existing.Amount = budget.Amount;
|
existing.Amount = budget.Amount;
|
||||||
existing.Period = budget.Period;
|
existing.Period = budget.Period;
|
||||||
existing.StartDate = budget.StartDate;
|
existing.StartDate = budget.StartDate;
|
||||||
existing.IsActive = budget.IsActive;
|
existing.IsActive = budget.IsActive;
|
||||||
existing.Notes = budget.Notes;
|
existing.Notes = budget.Notes;
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return new BudgetOperationResult
|
return new BudgetOperationResult
|
||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
Message = "Budget updated successfully.",
|
Message = "Budget updated successfully.",
|
||||||
BudgetId = existing.Id
|
BudgetId = existing.Id
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<BudgetOperationResult> DeleteBudgetAsync(int id)
|
public async Task<BudgetOperationResult> DeleteBudgetAsync(int id)
|
||||||
{
|
{
|
||||||
var budget = await _db.Budgets.FindAsync(id);
|
var budget = await _db.Budgets.FindAsync(id);
|
||||||
if (budget == null)
|
if (budget == null)
|
||||||
{
|
{
|
||||||
return new BudgetOperationResult
|
return new BudgetOperationResult
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "Budget not found."
|
Message = "Budget not found."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
_db.Budgets.Remove(budget);
|
_db.Budgets.Remove(budget);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return new BudgetOperationResult
|
return new BudgetOperationResult
|
||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
Message = "Budget deleted successfully."
|
Message = "Budget deleted successfully."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Budget Status Calculations
|
#region Budget Status Calculations
|
||||||
|
|
||||||
public async Task<List<BudgetStatus>> GetAllBudgetStatusesAsync(DateTime? asOfDate = null)
|
public async Task<List<BudgetStatus>> GetAllBudgetStatusesAsync(DateTime? asOfDate = null)
|
||||||
{
|
{
|
||||||
var date = asOfDate ?? DateTime.Today;
|
var date = asOfDate ?? DateTime.Today;
|
||||||
var budgets = await GetAllBudgetsAsync(activeOnly: true);
|
var budgets = await GetAllBudgetsAsync(activeOnly: true);
|
||||||
var statuses = new List<BudgetStatus>();
|
var statuses = new List<BudgetStatus>();
|
||||||
|
|
||||||
foreach (var budget in budgets)
|
foreach (var budget in budgets)
|
||||||
{
|
{
|
||||||
var status = await CalculateBudgetStatusAsync(budget, date);
|
var status = await CalculateBudgetStatusAsync(budget, date);
|
||||||
statuses.Add(status);
|
statuses.Add(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
return statuses;
|
return statuses;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<BudgetStatus?> GetBudgetStatusAsync(int budgetId, DateTime? asOfDate = null)
|
public async Task<BudgetStatus?> GetBudgetStatusAsync(int budgetId, DateTime? asOfDate = null)
|
||||||
{
|
{
|
||||||
var budget = await GetBudgetByIdAsync(budgetId);
|
var budget = await GetBudgetByIdAsync(budgetId);
|
||||||
if (budget == null)
|
if (budget == null)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var date = asOfDate ?? DateTime.Today;
|
var date = asOfDate ?? DateTime.Today;
|
||||||
return await CalculateBudgetStatusAsync(budget, date);
|
return await CalculateBudgetStatusAsync(budget, date);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<BudgetStatus> CalculateBudgetStatusAsync(Budget budget, DateTime asOfDate)
|
private async Task<BudgetStatus> CalculateBudgetStatusAsync(Budget budget, DateTime asOfDate)
|
||||||
{
|
{
|
||||||
var (periodStart, periodEnd) = GetPeriodBoundaries(budget.Period, budget.StartDate, asOfDate);
|
var (periodStart, periodEnd) = GetPeriodBoundaries(budget.Period, budget.StartDate, asOfDate);
|
||||||
|
|
||||||
// Calculate spending for the period
|
// Calculate spending for the period
|
||||||
var query = _db.Transactions
|
var query = _db.Transactions
|
||||||
.Where(t => t.Date >= periodStart && t.Date <= periodEnd)
|
.Where(t => t.Date >= periodStart && t.Date <= periodEnd)
|
||||||
.Where(t => t.Amount < 0) // Only debits (spending)
|
.Where(t => t.Amount < 0) // Only debits (spending)
|
||||||
.Where(t => t.TransferToAccountId == null); // Exclude transfers
|
.Where(t => t.TransferToAccountId == null); // Exclude transfers
|
||||||
|
|
||||||
// For category-specific budgets, filter by category (case-insensitive)
|
// For category-specific budgets, filter by category (case-insensitive)
|
||||||
if (budget.Category != null)
|
if (budget.Category != null)
|
||||||
{
|
{
|
||||||
query = query.Where(t => t.Category != null && t.Category.ToLower() == budget.Category.ToLower());
|
query = query.Where(t => t.Category != null && t.Category.ToLower() == budget.Category.ToLower());
|
||||||
}
|
}
|
||||||
|
|
||||||
var spent = await query.SumAsync(t => Math.Abs(t.Amount));
|
var spent = await query.SumAsync(t => Math.Abs(t.Amount));
|
||||||
var remaining = budget.Amount - spent;
|
var remaining = budget.Amount - spent;
|
||||||
var percentUsed = budget.Amount > 0 ? (spent / budget.Amount) * 100 : 0;
|
var percentUsed = budget.Amount > 0 ? (spent / budget.Amount) * 100 : 0;
|
||||||
|
|
||||||
return new BudgetStatus
|
return new BudgetStatus
|
||||||
{
|
{
|
||||||
Budget = budget,
|
Budget = budget,
|
||||||
PeriodStart = periodStart,
|
PeriodStart = periodStart,
|
||||||
PeriodEnd = periodEnd,
|
PeriodEnd = periodEnd,
|
||||||
Spent = spent,
|
Spent = spent,
|
||||||
Remaining = remaining,
|
Remaining = remaining,
|
||||||
PercentUsed = percentUsed,
|
PercentUsed = percentUsed,
|
||||||
IsOverBudget = spent > budget.Amount
|
IsOverBudget = spent > budget.Amount
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public (DateTime Start, DateTime End) GetPeriodBoundaries(BudgetPeriod period, DateTime startDate, DateTime asOfDate)
|
public (DateTime Start, DateTime End) GetPeriodBoundaries(BudgetPeriod period, DateTime startDate, DateTime asOfDate)
|
||||||
{
|
{
|
||||||
return period switch
|
return period switch
|
||||||
{
|
{
|
||||||
BudgetPeriod.Weekly => GetWeeklyBoundaries(startDate, asOfDate),
|
BudgetPeriod.Weekly => GetWeeklyBoundaries(startDate, asOfDate),
|
||||||
BudgetPeriod.Monthly => GetMonthlyBoundaries(startDate, asOfDate),
|
BudgetPeriod.Monthly => GetMonthlyBoundaries(startDate, asOfDate),
|
||||||
BudgetPeriod.Yearly => GetYearlyBoundaries(startDate, asOfDate),
|
BudgetPeriod.Yearly => GetYearlyBoundaries(startDate, asOfDate),
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(period))
|
_ => throw new ArgumentOutOfRangeException(nameof(period))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private (DateTime Start, DateTime End) GetWeeklyBoundaries(DateTime startDate, DateTime asOfDate)
|
private (DateTime Start, DateTime End) GetWeeklyBoundaries(DateTime startDate, DateTime asOfDate)
|
||||||
{
|
{
|
||||||
// Find which week we're in relative to the start date
|
// Find which week we're in relative to the start date
|
||||||
var daysSinceStart = (asOfDate - startDate.Date).Days;
|
var daysSinceStart = (asOfDate - startDate.Date).Days;
|
||||||
|
|
||||||
if (daysSinceStart < 0)
|
if (daysSinceStart < 0)
|
||||||
{
|
{
|
||||||
// Before start date - use the week containing start date
|
// Before start date - use the week containing start date
|
||||||
return (startDate.Date, startDate.Date.AddDays(6));
|
return (startDate.Date, startDate.Date.AddDays(6));
|
||||||
}
|
}
|
||||||
|
|
||||||
var weekNumber = daysSinceStart / 7;
|
var weekNumber = daysSinceStart / 7;
|
||||||
var periodStart = startDate.Date.AddDays(weekNumber * 7);
|
var periodStart = startDate.Date.AddDays(weekNumber * 7);
|
||||||
var periodEnd = periodStart.AddDays(6);
|
var periodEnd = periodStart.AddDays(6);
|
||||||
|
|
||||||
return (periodStart, periodEnd);
|
return (periodStart, periodEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
private (DateTime Start, DateTime End) GetMonthlyBoundaries(DateTime startDate, DateTime asOfDate)
|
private (DateTime Start, DateTime End) GetMonthlyBoundaries(DateTime startDate, DateTime asOfDate)
|
||||||
{
|
{
|
||||||
// Use the start date's day of month as the boundary
|
// Use the start date's day of month as the boundary
|
||||||
var dayOfMonth = Math.Min(startDate.Day, DateTime.DaysInMonth(asOfDate.Year, asOfDate.Month));
|
var dayOfMonth = Math.Min(startDate.Day, DateTime.DaysInMonth(asOfDate.Year, asOfDate.Month));
|
||||||
|
|
||||||
DateTime periodStart;
|
DateTime periodStart;
|
||||||
if (asOfDate.Day >= dayOfMonth)
|
if (asOfDate.Day >= dayOfMonth)
|
||||||
{
|
{
|
||||||
// We're in the current period
|
// We're in the current period
|
||||||
periodStart = new DateTime(asOfDate.Year, asOfDate.Month, dayOfMonth);
|
periodStart = new DateTime(asOfDate.Year, asOfDate.Month, dayOfMonth);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// We're before this month's boundary, so use last month
|
// We're before this month's boundary, so use last month
|
||||||
var lastMonth = asOfDate.AddMonths(-1);
|
var lastMonth = asOfDate.AddMonths(-1);
|
||||||
dayOfMonth = Math.Min(startDate.Day, DateTime.DaysInMonth(lastMonth.Year, lastMonth.Month));
|
dayOfMonth = Math.Min(startDate.Day, DateTime.DaysInMonth(lastMonth.Year, lastMonth.Month));
|
||||||
periodStart = new DateTime(lastMonth.Year, lastMonth.Month, dayOfMonth);
|
periodStart = new DateTime(lastMonth.Year, lastMonth.Month, dayOfMonth);
|
||||||
}
|
}
|
||||||
|
|
||||||
// End is the day before the next period starts
|
// End is the day before the next period starts
|
||||||
var nextPeriodStart = periodStart.AddMonths(1);
|
var nextPeriodStart = periodStart.AddMonths(1);
|
||||||
var nextDayOfMonth = Math.Min(startDate.Day, DateTime.DaysInMonth(nextPeriodStart.Year, nextPeriodStart.Month));
|
var nextDayOfMonth = Math.Min(startDate.Day, DateTime.DaysInMonth(nextPeriodStart.Year, nextPeriodStart.Month));
|
||||||
nextPeriodStart = new DateTime(nextPeriodStart.Year, nextPeriodStart.Month, nextDayOfMonth);
|
nextPeriodStart = new DateTime(nextPeriodStart.Year, nextPeriodStart.Month, nextDayOfMonth);
|
||||||
var periodEnd = nextPeriodStart.AddDays(-1);
|
var periodEnd = nextPeriodStart.AddDays(-1);
|
||||||
|
|
||||||
return (periodStart, periodEnd);
|
return (periodStart, periodEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
private (DateTime Start, DateTime End) GetYearlyBoundaries(DateTime startDate, DateTime asOfDate)
|
private (DateTime Start, DateTime End) GetYearlyBoundaries(DateTime startDate, DateTime asOfDate)
|
||||||
{
|
{
|
||||||
// Find which year period we're in
|
// Find which year period we're in
|
||||||
var yearsSinceStart = asOfDate.Year - startDate.Year;
|
var yearsSinceStart = asOfDate.Year - startDate.Year;
|
||||||
|
|
||||||
// Check if we're before the anniversary this year
|
// Check if we're before the anniversary this year
|
||||||
var anniversaryThisYear = new DateTime(asOfDate.Year, startDate.Month,
|
var anniversaryThisYear = new DateTime(asOfDate.Year, startDate.Month,
|
||||||
Math.Min(startDate.Day, DateTime.DaysInMonth(asOfDate.Year, startDate.Month)));
|
Math.Min(startDate.Day, DateTime.DaysInMonth(asOfDate.Year, startDate.Month)));
|
||||||
|
|
||||||
if (asOfDate < anniversaryThisYear)
|
if (asOfDate < anniversaryThisYear)
|
||||||
yearsSinceStart--;
|
yearsSinceStart--;
|
||||||
|
|
||||||
var periodStart = startDate.Date.AddYears(Math.Max(0, yearsSinceStart));
|
var periodStart = startDate.Date.AddYears(Math.Max(0, yearsSinceStart));
|
||||||
var periodEnd = periodStart.AddYears(1).AddDays(-1);
|
var periodEnd = periodStart.AddYears(1).AddDays(-1);
|
||||||
|
|
||||||
return (periodStart, periodEnd);
|
return (periodStart, periodEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Helper Methods
|
#region Helper Methods
|
||||||
|
|
||||||
public async Task<List<string>> GetAvailableCategoriesAsync()
|
public async Task<List<string>> GetAvailableCategoriesAsync()
|
||||||
{
|
{
|
||||||
return await _db.Transactions
|
return await _db.Transactions
|
||||||
.Where(t => !string.IsNullOrEmpty(t.Category))
|
.Where(t => !string.IsNullOrEmpty(t.Category))
|
||||||
.Select(t => t.Category)
|
.Select(t => t.Category)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.OrderBy(c => c)
|
.OrderBy(c => c)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|
||||||
// DTOs
|
// DTOs
|
||||||
public class BudgetOperationResult
|
public class BudgetOperationResult
|
||||||
{
|
{
|
||||||
public bool Success { get; set; }
|
public bool Success { get; set; }
|
||||||
public string Message { get; set; } = "";
|
public string Message { get; set; } = "";
|
||||||
public int? BudgetId { get; set; }
|
public int? BudgetId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class BudgetStatus
|
public class BudgetStatus
|
||||||
{
|
{
|
||||||
public Budget Budget { get; set; } = null!;
|
public Budget Budget { get; set; } = null!;
|
||||||
public DateTime PeriodStart { get; set; }
|
public DateTime PeriodStart { get; set; }
|
||||||
public DateTime PeriodEnd { get; set; }
|
public DateTime PeriodEnd { get; set; }
|
||||||
public decimal Spent { get; set; }
|
public decimal Spent { get; set; }
|
||||||
public decimal Remaining { get; set; }
|
public decimal Remaining { get; set; }
|
||||||
public decimal PercentUsed { get; set; }
|
public decimal PercentUsed { get; set; }
|
||||||
public bool IsOverBudget { get; set; }
|
public bool IsOverBudget { get; set; }
|
||||||
|
|
||||||
// Helper for display
|
// Helper for display
|
||||||
public string StatusClass => IsOverBudget ? "danger" : PercentUsed >= 80 ? "warning" : "success";
|
public string StatusClass => IsOverBudget ? "danger" : PercentUsed >= 80 ? "warning" : "success";
|
||||||
public string PeriodDisplay => $"{PeriodStart:MMM d} - {PeriodEnd:MMM d, yyyy}";
|
public string PeriodDisplay => $"{PeriodStart:MMM d} - {PeriodEnd:MMM d, yyyy}";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,158 +1,158 @@
|
|||||||
using MoneyMap.Data;
|
using MoneyMap.Data;
|
||||||
using MoneyMap.Models.Import;
|
using MoneyMap.Models.Import;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace MoneyMap.Services
|
namespace MoneyMap.Services
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Service for resolving payment methods (cards/accounts) for transactions.
|
/// Service for resolving payment methods (cards/accounts) for transactions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface ICardResolver
|
public interface ICardResolver
|
||||||
{
|
{
|
||||||
Task<PaymentResolutionResult> ResolvePaymentAsync(string? memo, ImportContext context);
|
Task<PaymentResolutionResult> ResolvePaymentAsync(string? memo, ImportContext context);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CardResolver : ICardResolver
|
public class CardResolver : ICardResolver
|
||||||
{
|
{
|
||||||
private readonly MoneyMapContext _db;
|
private readonly MoneyMapContext _db;
|
||||||
|
|
||||||
public CardResolver(MoneyMapContext db)
|
public CardResolver(MoneyMapContext db)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PaymentResolutionResult> ResolvePaymentAsync(string? memo, ImportContext context)
|
public async Task<PaymentResolutionResult> ResolvePaymentAsync(string? memo, ImportContext context)
|
||||||
{
|
{
|
||||||
if (context.PaymentMode == PaymentSelectMode.Card)
|
if (context.PaymentMode == PaymentSelectMode.Card)
|
||||||
return ResolveCard(context);
|
return ResolveCard(context);
|
||||||
|
|
||||||
if (context.PaymentMode == PaymentSelectMode.Account)
|
if (context.PaymentMode == PaymentSelectMode.Account)
|
||||||
return ResolveAccount(context);
|
return ResolveAccount(context);
|
||||||
|
|
||||||
return await ResolveAutomaticallyAsync(memo, context);
|
return await ResolveAutomaticallyAsync(memo, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
private PaymentResolutionResult ResolveCard(ImportContext context)
|
private PaymentResolutionResult ResolveCard(ImportContext context)
|
||||||
{
|
{
|
||||||
if (context.SelectedCardId is null)
|
if (context.SelectedCardId is null)
|
||||||
return PaymentResolutionResult.Failure("Pick a card or switch to Auto.");
|
return PaymentResolutionResult.Failure("Pick a card or switch to Auto.");
|
||||||
|
|
||||||
var card = context.AvailableCards.FirstOrDefault(c => c.Id == context.SelectedCardId);
|
var card = context.AvailableCards.FirstOrDefault(c => c.Id == context.SelectedCardId);
|
||||||
if (card is null)
|
if (card is null)
|
||||||
return PaymentResolutionResult.Failure("Selected card not found.");
|
return PaymentResolutionResult.Failure("Selected card not found.");
|
||||||
|
|
||||||
// Card must have a linked account
|
// Card must have a linked account
|
||||||
if (!card.AccountId.HasValue)
|
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.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);
|
return PaymentResolutionResult.SuccessCard(card.Id, card.AccountId.Value, card.Last4);
|
||||||
}
|
}
|
||||||
|
|
||||||
private PaymentResolutionResult ResolveAccount(ImportContext context)
|
private PaymentResolutionResult ResolveAccount(ImportContext context)
|
||||||
{
|
{
|
||||||
if (context.SelectedAccountId is null)
|
if (context.SelectedAccountId is null)
|
||||||
return PaymentResolutionResult.Failure("Pick an account or switch to Auto/Card mode.");
|
return PaymentResolutionResult.Failure("Pick an account or switch to Auto/Card mode.");
|
||||||
|
|
||||||
var account = context.AvailableAccounts.FirstOrDefault(a => a.Id == context.SelectedAccountId);
|
var account = context.AvailableAccounts.FirstOrDefault(a => a.Id == context.SelectedAccountId);
|
||||||
if (account is null)
|
if (account is null)
|
||||||
return PaymentResolutionResult.Failure("Selected account not found.");
|
return PaymentResolutionResult.Failure("Selected account not found.");
|
||||||
|
|
||||||
return PaymentResolutionResult.SuccessAccount(account.Id, account.Last4);
|
return PaymentResolutionResult.SuccessAccount(account.Id, account.Last4);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task<PaymentResolutionResult> ResolveAutomaticallyAsync(string? memo, ImportContext context)
|
private Task<PaymentResolutionResult> ResolveAutomaticallyAsync(string? memo, ImportContext context)
|
||||||
{
|
{
|
||||||
// Extract last4 from both memo and filename
|
// Extract last4 from both memo and filename
|
||||||
var last4FromFile = CardIdentifierExtractor.FromFileName(context.FileName);
|
var last4FromFile = CardIdentifierExtractor.FromFileName(context.FileName);
|
||||||
var last4FromMemo = CardIdentifierExtractor.FromMemo(memo);
|
var last4FromMemo = CardIdentifierExtractor.FromMemo(memo);
|
||||||
|
|
||||||
// PRIORITY 1: Try memo first (for per-transaction card detection like "usbank.com.2765")
|
// PRIORITY 1: Try memo first (for per-transaction card detection like "usbank.com.2765")
|
||||||
if (!string.IsNullOrWhiteSpace(last4FromMemo))
|
if (!string.IsNullOrWhiteSpace(last4FromMemo))
|
||||||
{
|
{
|
||||||
var result = TryResolveByLast4(last4FromMemo, context);
|
var result = TryResolveByLast4(last4FromMemo, context);
|
||||||
if (result != null) return Task.FromResult(result);
|
if (result != null) return Task.FromResult(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PRIORITY 2: Fall back to filename (for account-level CSVs or when memo has no card)
|
// PRIORITY 2: Fall back to filename (for account-level CSVs or when memo has no card)
|
||||||
if (!string.IsNullOrWhiteSpace(last4FromFile))
|
if (!string.IsNullOrWhiteSpace(last4FromFile))
|
||||||
{
|
{
|
||||||
var result = TryResolveByLast4(last4FromFile, context);
|
var result = TryResolveByLast4(last4FromFile, context);
|
||||||
if (result != null) return Task.FromResult(result);
|
if (result != null) return Task.FromResult(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nothing found - error
|
// Nothing found - error
|
||||||
var searchedLast4 = last4FromMemo ?? last4FromFile;
|
var searchedLast4 = last4FromMemo ?? last4FromFile;
|
||||||
if (string.IsNullOrWhiteSpace(searchedLast4))
|
if (string.IsNullOrWhiteSpace(searchedLast4))
|
||||||
{
|
{
|
||||||
return Task.FromResult(PaymentResolutionResult.Failure(
|
return Task.FromResult(PaymentResolutionResult.Failure(
|
||||||
"Couldn't determine card or account from memo or file name. Choose an account manually."));
|
"Couldn't determine card or account from memo or file name. Choose an account manually."));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.FromResult(PaymentResolutionResult.Failure(
|
return Task.FromResult(PaymentResolutionResult.Failure(
|
||||||
$"Couldn't find account or card with last4 '{searchedLast4}'. Choose an account manually."));
|
$"Couldn't find account or card with last4 '{searchedLast4}'. Choose an account manually."));
|
||||||
}
|
}
|
||||||
|
|
||||||
private PaymentResolutionResult? TryResolveByLast4(string last4, ImportContext context)
|
private PaymentResolutionResult? TryResolveByLast4(string last4, ImportContext context)
|
||||||
{
|
{
|
||||||
// Look for both card and account matches
|
// Look for both card and account matches
|
||||||
var matchingCard = context.AvailableCards.FirstOrDefault(c => c.Last4 == last4);
|
var matchingCard = context.AvailableCards.FirstOrDefault(c => c.Last4 == last4);
|
||||||
var matchingAccount = context.AvailableAccounts.FirstOrDefault(a => a.Last4 == last4);
|
var matchingAccount = context.AvailableAccounts.FirstOrDefault(a => a.Last4 == last4);
|
||||||
|
|
||||||
// Prioritize card matches (for credit card CSVs or memo-based card detection)
|
// Prioritize card matches (for credit card CSVs or memo-based card detection)
|
||||||
if (matchingCard != null)
|
if (matchingCard != null)
|
||||||
{
|
{
|
||||||
// Card found - it must have an account
|
// Card found - it must have an account
|
||||||
if (!matchingCard.AccountId.HasValue)
|
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.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);
|
return PaymentResolutionResult.SuccessCard(matchingCard.Id, matchingCard.AccountId.Value, matchingCard.Last4);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to account match (for direct account transactions)
|
// Fall back to account match (for direct account transactions)
|
||||||
if (matchingAccount != null)
|
if (matchingAccount != null)
|
||||||
{
|
{
|
||||||
return PaymentResolutionResult.SuccessAccount(matchingAccount.Id, matchingAccount.Last4);
|
return PaymentResolutionResult.SuccessAccount(matchingAccount.Id, matchingAccount.Last4);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null; // No match found
|
return null; // No match found
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Utility class for extracting card/account identifiers from memos and filenames.
|
/// Utility class for extracting card/account identifiers from memos and filenames.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class CardIdentifierExtractor
|
public static class CardIdentifierExtractor
|
||||||
{
|
{
|
||||||
// Match patterns: "usbank.com.2765" or "usbank.com.0479" or similar formats
|
// Match patterns: "usbank.com.2765" or "usbank.com.0479" or similar formats
|
||||||
private static readonly Regex MemoLast4Pattern = new(@"\.(\d{4})(?:\D|$)", RegexOptions.Compiled);
|
private static readonly Regex MemoLast4Pattern = new(@"\.(\d{4})(?:\D|$)", RegexOptions.Compiled);
|
||||||
private const int Last4Length = 4;
|
private const int Last4Length = 4;
|
||||||
|
|
||||||
public static string? FromMemo(string? memo)
|
public static string? FromMemo(string? memo)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(memo))
|
if (string.IsNullOrWhiteSpace(memo))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
// Try to find all matches and return the last one (most likely to be card/account number)
|
// Try to find all matches and return the last one (most likely to be card/account number)
|
||||||
var matches = MemoLast4Pattern.Matches(memo);
|
var matches = MemoLast4Pattern.Matches(memo);
|
||||||
if (matches.Count == 0)
|
if (matches.Count == 0)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
// Return the last match (typically the card number at the end)
|
// Return the last match (typically the card number at the end)
|
||||||
return matches[^1].Groups[1].Value;
|
return matches[^1].Groups[1].Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string? FromFileName(string fileName)
|
public static string? FromFileName(string fileName)
|
||||||
{
|
{
|
||||||
var name = Path.GetFileNameWithoutExtension(fileName);
|
var name = Path.GetFileNameWithoutExtension(fileName);
|
||||||
var parts = name.Split(new[] { '-', '_', ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
var parts = name.Split(new[] { '-', '_', ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
foreach (var part in parts.Select(p => p.Trim()))
|
foreach (var part in parts.Select(p => p.Trim()))
|
||||||
{
|
{
|
||||||
if (part.Length == Last4Length && int.TryParse(part, out _))
|
if (part.Length == Last4Length && int.TryParse(part, out _))
|
||||||
return part;
|
return part;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,123 +1,123 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using MoneyMap.Data;
|
using MoneyMap.Data;
|
||||||
using MoneyMap.Models;
|
using MoneyMap.Models;
|
||||||
|
|
||||||
namespace MoneyMap.Services;
|
namespace MoneyMap.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Service for card management including retrieval, validation, and deletion.
|
/// Service for card management including retrieval, validation, and deletion.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface ICardService
|
public interface ICardService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets a card by ID with optional related data.
|
/// Gets a card by ID with optional related data.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Card?> GetCardByIdAsync(int id, bool includeRelated = false);
|
Task<Card?> GetCardByIdAsync(int id, bool includeRelated = false);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets all cards with transaction statistics.
|
/// Gets all cards with transaction statistics.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<CardWithStats>> GetAllCardsWithStatsAsync();
|
Task<List<CardWithStats>> GetAllCardsWithStatsAsync();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks if a card can be deleted (no transactions exist).
|
/// Checks if a card can be deleted (no transactions exist).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<DeleteValidationResult> CanDeleteCardAsync(int id);
|
Task<DeleteValidationResult> CanDeleteCardAsync(int id);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deletes a card if it has no associated transactions.
|
/// Deletes a card if it has no associated transactions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<DeleteResult> DeleteCardAsync(int id);
|
Task<DeleteResult> DeleteCardAsync(int id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CardService : ICardService
|
public class CardService : ICardService
|
||||||
{
|
{
|
||||||
private readonly MoneyMapContext _db;
|
private readonly MoneyMapContext _db;
|
||||||
|
|
||||||
public CardService(MoneyMapContext db)
|
public CardService(MoneyMapContext db)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Card?> GetCardByIdAsync(int id, bool includeRelated = false)
|
public async Task<Card?> GetCardByIdAsync(int id, bool includeRelated = false)
|
||||||
{
|
{
|
||||||
var query = _db.Cards.AsQueryable();
|
var query = _db.Cards.AsQueryable();
|
||||||
|
|
||||||
if (includeRelated)
|
if (includeRelated)
|
||||||
{
|
{
|
||||||
query = query
|
query = query
|
||||||
.Include(c => c.Account)
|
.Include(c => c.Account)
|
||||||
.Include(c => c.Transactions);
|
.Include(c => c.Transactions);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await query.FirstOrDefaultAsync(c => c.Id == id);
|
return await query.FirstOrDefaultAsync(c => c.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<CardWithStats>> GetAllCardsWithStatsAsync()
|
public async Task<List<CardWithStats>> GetAllCardsWithStatsAsync()
|
||||||
{
|
{
|
||||||
// Single query with projection to avoid N+1
|
// Single query with projection to avoid N+1
|
||||||
return await _db.Cards
|
return await _db.Cards
|
||||||
.Include(c => c.Account)
|
.Include(c => c.Account)
|
||||||
.OrderBy(c => c.Owner)
|
.OrderBy(c => c.Owner)
|
||||||
.ThenBy(c => c.Last4)
|
.ThenBy(c => c.Last4)
|
||||||
.Select(c => new CardWithStats
|
.Select(c => new CardWithStats
|
||||||
{
|
{
|
||||||
Card = c,
|
Card = c,
|
||||||
TransactionCount = c.Transactions.Count
|
TransactionCount = c.Transactions.Count
|
||||||
})
|
})
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<DeleteValidationResult> CanDeleteCardAsync(int id)
|
public async Task<DeleteValidationResult> CanDeleteCardAsync(int id)
|
||||||
{
|
{
|
||||||
var card = await _db.Cards.FindAsync(id);
|
var card = await _db.Cards.FindAsync(id);
|
||||||
if (card == null)
|
if (card == null)
|
||||||
return new DeleteValidationResult
|
return new DeleteValidationResult
|
||||||
{
|
{
|
||||||
CanDelete = false,
|
CanDelete = false,
|
||||||
Reason = "Card not found."
|
Reason = "Card not found."
|
||||||
};
|
};
|
||||||
|
|
||||||
var transactionCount = await _db.Transactions.CountAsync(t => t.CardId == id);
|
var transactionCount = await _db.Transactions.CountAsync(t => t.CardId == id);
|
||||||
if (transactionCount > 0)
|
if (transactionCount > 0)
|
||||||
return new DeleteValidationResult
|
return new DeleteValidationResult
|
||||||
{
|
{
|
||||||
CanDelete = false,
|
CanDelete = false,
|
||||||
Reason = $"Cannot delete card. It has {transactionCount} transaction(s) associated with it."
|
Reason = $"Cannot delete card. It has {transactionCount} transaction(s) associated with it."
|
||||||
};
|
};
|
||||||
|
|
||||||
return new DeleteValidationResult { CanDelete = true };
|
return new DeleteValidationResult { CanDelete = true };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<DeleteResult> DeleteCardAsync(int id)
|
public async Task<DeleteResult> DeleteCardAsync(int id)
|
||||||
{
|
{
|
||||||
var validation = await CanDeleteCardAsync(id);
|
var validation = await CanDeleteCardAsync(id);
|
||||||
if (!validation.CanDelete)
|
if (!validation.CanDelete)
|
||||||
{
|
{
|
||||||
return new DeleteResult
|
return new DeleteResult
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = validation.Reason ?? "Cannot delete card."
|
Message = validation.Reason ?? "Cannot delete card."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
var card = await _db.Cards.FindAsync(id);
|
var card = await _db.Cards.FindAsync(id);
|
||||||
if (card == null)
|
if (card == null)
|
||||||
{
|
{
|
||||||
return new DeleteResult
|
return new DeleteResult
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "Card not found."
|
Message = "Card not found."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
_db.Cards.Remove(card);
|
_db.Cards.Remove(card);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return new DeleteResult
|
return new DeleteResult
|
||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
Message = "Card deleted successfully."
|
Message = "Card deleted successfully."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,268 +1,268 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using MoneyMap.Data;
|
using MoneyMap.Data;
|
||||||
using MoneyMap.Models.Dashboard;
|
using MoneyMap.Models.Dashboard;
|
||||||
|
|
||||||
namespace MoneyMap.Services
|
namespace MoneyMap.Services
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Service for retrieving dashboard data.
|
/// Service for retrieving dashboard data.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IDashboardService
|
public interface IDashboardService
|
||||||
{
|
{
|
||||||
Task<DashboardData> GetDashboardDataAsync(int topCategoriesCount = 8, int recentTransactionsCount = 20);
|
Task<DashboardData> GetDashboardDataAsync(int topCategoriesCount = 8, int recentTransactionsCount = 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class DashboardService : IDashboardService
|
public class DashboardService : IDashboardService
|
||||||
{
|
{
|
||||||
private readonly IDashboardStatsCalculator _statsCalculator;
|
private readonly IDashboardStatsCalculator _statsCalculator;
|
||||||
private readonly ITopCategoriesProvider _topCategoriesProvider;
|
private readonly ITopCategoriesProvider _topCategoriesProvider;
|
||||||
private readonly IRecentTransactionsProvider _recentTransactionsProvider;
|
private readonly IRecentTransactionsProvider _recentTransactionsProvider;
|
||||||
private readonly ISpendTrendsProvider _spendTrendsProvider;
|
private readonly ISpendTrendsProvider _spendTrendsProvider;
|
||||||
|
|
||||||
public DashboardService(
|
public DashboardService(
|
||||||
IDashboardStatsCalculator statsCalculator,
|
IDashboardStatsCalculator statsCalculator,
|
||||||
ITopCategoriesProvider topCategoriesProvider,
|
ITopCategoriesProvider topCategoriesProvider,
|
||||||
IRecentTransactionsProvider recentTransactionsProvider,
|
IRecentTransactionsProvider recentTransactionsProvider,
|
||||||
ISpendTrendsProvider spendTrendsProvider)
|
ISpendTrendsProvider spendTrendsProvider)
|
||||||
{
|
{
|
||||||
_statsCalculator = statsCalculator;
|
_statsCalculator = statsCalculator;
|
||||||
_topCategoriesProvider = topCategoriesProvider;
|
_topCategoriesProvider = topCategoriesProvider;
|
||||||
_recentTransactionsProvider = recentTransactionsProvider;
|
_recentTransactionsProvider = recentTransactionsProvider;
|
||||||
_spendTrendsProvider = spendTrendsProvider;
|
_spendTrendsProvider = spendTrendsProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<DashboardData> GetDashboardDataAsync(int topCategoriesCount = 8, int recentTransactionsCount = 20)
|
public async Task<DashboardData> GetDashboardDataAsync(int topCategoriesCount = 8, int recentTransactionsCount = 20)
|
||||||
{
|
{
|
||||||
var stats = await _statsCalculator.CalculateAsync();
|
var stats = await _statsCalculator.CalculateAsync();
|
||||||
var topCategories = await _topCategoriesProvider.GetTopCategoriesAsync(topCategoriesCount);
|
var topCategories = await _topCategoriesProvider.GetTopCategoriesAsync(topCategoriesCount);
|
||||||
var recent = await _recentTransactionsProvider.GetRecentTransactionsAsync(recentTransactionsCount);
|
var recent = await _recentTransactionsProvider.GetRecentTransactionsAsync(recentTransactionsCount);
|
||||||
var trends = await _spendTrendsProvider.GetDailyTrendsAsync(30);
|
var trends = await _spendTrendsProvider.GetDailyTrendsAsync(30);
|
||||||
|
|
||||||
return new DashboardData
|
return new DashboardData
|
||||||
{
|
{
|
||||||
Stats = stats,
|
Stats = stats,
|
||||||
TopCategories = topCategories,
|
TopCategories = topCategories,
|
||||||
RecentTransactions = recent,
|
RecentTransactions = recent,
|
||||||
Trends = trends
|
Trends = trends
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Calculates dashboard statistics.
|
/// Calculates dashboard statistics.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IDashboardStatsCalculator
|
public interface IDashboardStatsCalculator
|
||||||
{
|
{
|
||||||
Task<DashboardStats> CalculateAsync();
|
Task<DashboardStats> CalculateAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class DashboardStatsCalculator : IDashboardStatsCalculator
|
public class DashboardStatsCalculator : IDashboardStatsCalculator
|
||||||
{
|
{
|
||||||
private readonly MoneyMapContext _db;
|
private readonly MoneyMapContext _db;
|
||||||
|
|
||||||
public DashboardStatsCalculator(MoneyMapContext db)
|
public DashboardStatsCalculator(MoneyMapContext db)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<DashboardStats> CalculateAsync()
|
public async Task<DashboardStats> CalculateAsync()
|
||||||
{
|
{
|
||||||
var transactionStats = await GetTransactionStatsAsync();
|
var transactionStats = await GetTransactionStatsAsync();
|
||||||
var receiptsCount = await _db.Receipts.CountAsync();
|
var receiptsCount = await _db.Receipts.CountAsync();
|
||||||
var cardsCount = await _db.Cards.CountAsync();
|
var cardsCount = await _db.Cards.CountAsync();
|
||||||
|
|
||||||
return new DashboardStats(
|
return new DashboardStats(
|
||||||
transactionStats.Total,
|
transactionStats.Total,
|
||||||
transactionStats.Credits,
|
transactionStats.Credits,
|
||||||
transactionStats.Debits,
|
transactionStats.Debits,
|
||||||
transactionStats.Uncategorized,
|
transactionStats.Uncategorized,
|
||||||
receiptsCount,
|
receiptsCount,
|
||||||
cardsCount
|
cardsCount
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<TransactionStatsInternal> GetTransactionStatsAsync()
|
private async Task<TransactionStatsInternal> GetTransactionStatsAsync()
|
||||||
{
|
{
|
||||||
var stats = await _db.Transactions
|
var stats = await _db.Transactions
|
||||||
.GroupBy(_ => 1)
|
.GroupBy(_ => 1)
|
||||||
.Select(g => new TransactionStatsInternal
|
.Select(g => new TransactionStatsInternal
|
||||||
{
|
{
|
||||||
Total = g.Count(),
|
Total = g.Count(),
|
||||||
Credits = g.Count(t => t.Amount > 0),
|
Credits = g.Count(t => t.Amount > 0),
|
||||||
Debits = g.Count(t => t.Amount < 0),
|
Debits = g.Count(t => t.Amount < 0),
|
||||||
Uncategorized = g.Count(t => t.Category == null || t.Category == "")
|
Uncategorized = g.Count(t => t.Category == null || t.Category == "")
|
||||||
})
|
})
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
return stats ?? new TransactionStatsInternal();
|
return stats ?? new TransactionStatsInternal();
|
||||||
}
|
}
|
||||||
|
|
||||||
private class TransactionStatsInternal
|
private class TransactionStatsInternal
|
||||||
{
|
{
|
||||||
public int Total { get; set; }
|
public int Total { get; set; }
|
||||||
public int Credits { get; set; }
|
public int Credits { get; set; }
|
||||||
public int Debits { get; set; }
|
public int Debits { get; set; }
|
||||||
public int Uncategorized { get; set; }
|
public int Uncategorized { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Provides top spending categories.
|
/// Provides top spending categories.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface ITopCategoriesProvider
|
public interface ITopCategoriesProvider
|
||||||
{
|
{
|
||||||
Task<List<TopCategoryRow>> GetTopCategoriesAsync(int count = 8, int lastDays = 90);
|
Task<List<TopCategoryRow>> GetTopCategoriesAsync(int count = 8, int lastDays = 90);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class TopCategoriesProvider : ITopCategoriesProvider
|
public class TopCategoriesProvider : ITopCategoriesProvider
|
||||||
{
|
{
|
||||||
private readonly MoneyMapContext _db;
|
private readonly MoneyMapContext _db;
|
||||||
|
|
||||||
public TopCategoriesProvider(MoneyMapContext db)
|
public TopCategoriesProvider(MoneyMapContext db)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<TopCategoryRow>> GetTopCategoriesAsync(int count = 8, int lastDays = 90)
|
public async Task<List<TopCategoryRow>> GetTopCategoriesAsync(int count = 8, int lastDays = 90)
|
||||||
{
|
{
|
||||||
var since = DateTime.UtcNow.Date.AddDays(-lastDays);
|
var since = DateTime.UtcNow.Date.AddDays(-lastDays);
|
||||||
|
|
||||||
var expenseTransactions = await _db.Transactions
|
var expenseTransactions = await _db.Transactions
|
||||||
.Where(t => t.Date >= since && t.Amount < 0)
|
.Where(t => t.Date >= since && t.Amount < 0)
|
||||||
.ExcludeTransfers()
|
.ExcludeTransfers()
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var totalSpend = expenseTransactions.Sum(t => -t.Amount);
|
var totalSpend = expenseTransactions.Sum(t => -t.Amount);
|
||||||
|
|
||||||
var topCategories = expenseTransactions
|
var topCategories = expenseTransactions
|
||||||
.GroupBy(t => t.Category ?? "")
|
.GroupBy(t => t.Category ?? "")
|
||||||
.Select(g => new TopCategoryRow
|
.Select(g => new TopCategoryRow
|
||||||
{
|
{
|
||||||
Category = g.Key,
|
Category = g.Key,
|
||||||
TotalSpend = g.Sum(x => -x.Amount),
|
TotalSpend = g.Sum(x => -x.Amount),
|
||||||
Count = g.Count(),
|
Count = g.Count(),
|
||||||
PercentageOfTotal = totalSpend > 0 ? (g.Sum(x => -x.Amount) / totalSpend) * 100 : 0,
|
PercentageOfTotal = totalSpend > 0 ? (g.Sum(x => -x.Amount) / totalSpend) * 100 : 0,
|
||||||
AveragePerTransaction = g.Count() > 0 ? g.Sum(x => -x.Amount) / g.Count() : 0
|
AveragePerTransaction = g.Count() > 0 ? g.Sum(x => -x.Amount) / g.Count() : 0
|
||||||
})
|
})
|
||||||
.OrderByDescending(x => x.TotalSpend)
|
.OrderByDescending(x => x.TotalSpend)
|
||||||
.Take(count)
|
.Take(count)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return topCategories;
|
return topCategories;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Provides recent transactions.
|
/// Provides recent transactions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IRecentTransactionsProvider
|
public interface IRecentTransactionsProvider
|
||||||
{
|
{
|
||||||
Task<List<RecentTransactionRow>> GetRecentTransactionsAsync(int count = 20);
|
Task<List<RecentTransactionRow>> GetRecentTransactionsAsync(int count = 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class RecentTransactionsProvider : IRecentTransactionsProvider
|
public class RecentTransactionsProvider : IRecentTransactionsProvider
|
||||||
{
|
{
|
||||||
private readonly MoneyMapContext _db;
|
private readonly MoneyMapContext _db;
|
||||||
|
|
||||||
public RecentTransactionsProvider(MoneyMapContext db)
|
public RecentTransactionsProvider(MoneyMapContext db)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<RecentTransactionRow>> GetRecentTransactionsAsync(int count = 20)
|
public async Task<List<RecentTransactionRow>> GetRecentTransactionsAsync(int count = 20)
|
||||||
{
|
{
|
||||||
return await _db.Transactions
|
return await _db.Transactions
|
||||||
.Include(t => t.Card)
|
.Include(t => t.Card)
|
||||||
.OrderByDescending(t => t.Date)
|
.OrderByDescending(t => t.Date)
|
||||||
.ThenByDescending(t => t.Id)
|
.ThenByDescending(t => t.Id)
|
||||||
.Select(t => new RecentTransactionRow
|
.Select(t => new RecentTransactionRow
|
||||||
{
|
{
|
||||||
Id = t.Id,
|
Id = t.Id,
|
||||||
Date = t.Date,
|
Date = t.Date,
|
||||||
Name = t.Name,
|
Name = t.Name,
|
||||||
Memo = t.Memo,
|
Memo = t.Memo,
|
||||||
Amount = t.Amount,
|
Amount = t.Amount,
|
||||||
Category = t.Category ?? "",
|
Category = t.Category ?? "",
|
||||||
CardLabel = t.PaymentMethodLabel,
|
CardLabel = t.PaymentMethodLabel,
|
||||||
ReceiptCount = t.Receipts.Count()
|
ReceiptCount = t.Receipts.Count()
|
||||||
})
|
})
|
||||||
.Take(count)
|
.Take(count)
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Provides spending trends over time.
|
/// Provides spending trends over time.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface ISpendTrendsProvider
|
public interface ISpendTrendsProvider
|
||||||
{
|
{
|
||||||
Task<SpendTrends> GetDailyTrendsAsync(int lastDays = 30);
|
Task<SpendTrends> GetDailyTrendsAsync(int lastDays = 30);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SpendTrendsProvider : ISpendTrendsProvider
|
public class SpendTrendsProvider : ISpendTrendsProvider
|
||||||
{
|
{
|
||||||
private readonly MoneyMapContext _db;
|
private readonly MoneyMapContext _db;
|
||||||
|
|
||||||
public SpendTrendsProvider(MoneyMapContext db)
|
public SpendTrendsProvider(MoneyMapContext db)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SpendTrends> GetDailyTrendsAsync(int lastDays = 30)
|
public async Task<SpendTrends> GetDailyTrendsAsync(int lastDays = 30)
|
||||||
{
|
{
|
||||||
var today = DateTime.UtcNow.Date;
|
var today = DateTime.UtcNow.Date;
|
||||||
var since = today.AddDays(-(lastDays - 1));
|
var since = today.AddDays(-(lastDays - 1));
|
||||||
|
|
||||||
var raw = await _db.Transactions
|
var raw = await _db.Transactions
|
||||||
.Where(t => t.Date >= since)
|
.Where(t => t.Date >= since)
|
||||||
.ExcludeTransfers()
|
.ExcludeTransfers()
|
||||||
.GroupBy(t => t.Date.Date)
|
.GroupBy(t => t.Date.Date)
|
||||||
.Select(g => new
|
.Select(g => new
|
||||||
{
|
{
|
||||||
Date = g.Key,
|
Date = g.Key,
|
||||||
Debits = g.Where(t => t.Amount < 0).Sum(t => t.Amount),
|
Debits = g.Where(t => t.Amount < 0).Sum(t => t.Amount),
|
||||||
Credits = g.Where(t => t.Amount > 0).Sum(t => t.Amount)
|
Credits = g.Where(t => t.Amount > 0).Sum(t => t.Amount)
|
||||||
})
|
})
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var dict = raw.ToDictionary(x => x.Date, x => x);
|
var dict = raw.ToDictionary(x => x.Date, x => x);
|
||||||
var labels = new List<string>();
|
var labels = new List<string>();
|
||||||
var debitsAbs = new List<decimal>();
|
var debitsAbs = new List<decimal>();
|
||||||
var credits = new List<decimal>();
|
var credits = new List<decimal>();
|
||||||
var net = new List<decimal>();
|
var net = new List<decimal>();
|
||||||
var runningBalance = new List<decimal>();
|
var runningBalance = new List<decimal>();
|
||||||
decimal cumulative = 0;
|
decimal cumulative = 0;
|
||||||
|
|
||||||
for (var d = since; d <= today; d = d.AddDays(1))
|
for (var d = since; d <= today; d = d.AddDays(1))
|
||||||
{
|
{
|
||||||
labels.Add(d.ToString("MMM d"));
|
labels.Add(d.ToString("MMM d"));
|
||||||
if (dict.TryGetValue(d, out var v))
|
if (dict.TryGetValue(d, out var v))
|
||||||
{
|
{
|
||||||
var debit = v.Debits;
|
var debit = v.Debits;
|
||||||
var credit = v.Credits;
|
var credit = v.Credits;
|
||||||
debitsAbs.Add(Math.Abs(debit));
|
debitsAbs.Add(Math.Abs(debit));
|
||||||
credits.Add(credit);
|
credits.Add(credit);
|
||||||
net.Add(credit + debit);
|
net.Add(credit + debit);
|
||||||
cumulative += credit + debit;
|
cumulative += credit + debit;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
debitsAbs.Add(0);
|
debitsAbs.Add(0);
|
||||||
credits.Add(0);
|
credits.Add(0);
|
||||||
net.Add(0);
|
net.Add(0);
|
||||||
}
|
}
|
||||||
runningBalance.Add(cumulative);
|
runningBalance.Add(cumulative);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new SpendTrends
|
return new SpendTrends
|
||||||
{
|
{
|
||||||
Labels = labels,
|
Labels = labels,
|
||||||
DebitsAbs = debitsAbs,
|
DebitsAbs = debitsAbs,
|
||||||
Credits = credits,
|
Credits = credits,
|
||||||
Net = net,
|
Net = net,
|
||||||
RunningBalance = runningBalance
|
RunningBalance = runningBalance
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,491 +1,491 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using MoneyMap.Data;
|
using MoneyMap.Data;
|
||||||
using MoneyMap.Models;
|
using MoneyMap.Models;
|
||||||
using MoneyMap.Models.Api;
|
using MoneyMap.Models.Api;
|
||||||
|
|
||||||
namespace MoneyMap.Services;
|
namespace MoneyMap.Services;
|
||||||
|
|
||||||
public interface IFinancialAuditService
|
public interface IFinancialAuditService
|
||||||
{
|
{
|
||||||
Task<FinancialAuditResponse> GenerateAuditAsync(
|
Task<FinancialAuditResponse> GenerateAuditAsync(
|
||||||
DateTime startDate,
|
DateTime startDate,
|
||||||
DateTime endDate,
|
DateTime endDate,
|
||||||
bool includeTransactions = false);
|
bool includeTransactions = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class FinancialAuditService : IFinancialAuditService
|
public class FinancialAuditService : IFinancialAuditService
|
||||||
{
|
{
|
||||||
private readonly MoneyMapContext _db;
|
private readonly MoneyMapContext _db;
|
||||||
private readonly IBudgetService _budgetService;
|
private readonly IBudgetService _budgetService;
|
||||||
|
|
||||||
public FinancialAuditService(MoneyMapContext db, IBudgetService budgetService)
|
public FinancialAuditService(MoneyMapContext db, IBudgetService budgetService)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_budgetService = budgetService;
|
_budgetService = budgetService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<FinancialAuditResponse> GenerateAuditAsync(
|
public async Task<FinancialAuditResponse> GenerateAuditAsync(
|
||||||
DateTime startDate,
|
DateTime startDate,
|
||||||
DateTime endDate,
|
DateTime endDate,
|
||||||
bool includeTransactions = false)
|
bool includeTransactions = false)
|
||||||
{
|
{
|
||||||
var response = new FinancialAuditResponse
|
var response = new FinancialAuditResponse
|
||||||
{
|
{
|
||||||
GeneratedAt = DateTime.UtcNow,
|
GeneratedAt = DateTime.UtcNow,
|
||||||
PeriodStart = startDate.Date,
|
PeriodStart = startDate.Date,
|
||||||
PeriodEnd = endDate.Date
|
PeriodEnd = endDate.Date
|
||||||
};
|
};
|
||||||
|
|
||||||
// Base query for the period
|
// Base query for the period
|
||||||
var periodTransactions = _db.Transactions
|
var periodTransactions = _db.Transactions
|
||||||
.Include(t => t.Account)
|
.Include(t => t.Account)
|
||||||
.Include(t => t.Card)
|
.Include(t => t.Card)
|
||||||
.Include(t => t.Merchant)
|
.Include(t => t.Merchant)
|
||||||
.Where(t => t.Date >= startDate.Date && t.Date <= endDate.Date)
|
.Where(t => t.Date >= startDate.Date && t.Date <= endDate.Date)
|
||||||
.AsNoTracking();
|
.AsNoTracking();
|
||||||
|
|
||||||
// Calculate all sections in parallel where possible
|
// Calculate all sections in parallel where possible
|
||||||
response.Summary = await CalculateSummaryAsync(periodTransactions, startDate, endDate);
|
response.Summary = await CalculateSummaryAsync(periodTransactions, startDate, endDate);
|
||||||
response.Budgets = await GetBudgetStatusesAsync();
|
response.Budgets = await GetBudgetStatusesAsync();
|
||||||
response.SpendingByCategory = await GetCategorySpendingAsync(periodTransactions, response.Budgets);
|
response.SpendingByCategory = await GetCategorySpendingAsync(periodTransactions, response.Budgets);
|
||||||
response.TopMerchants = await GetMerchantSpendingAsync(periodTransactions);
|
response.TopMerchants = await GetMerchantSpendingAsync(periodTransactions);
|
||||||
response.MonthlyTrends = await GetMonthlyTrendsAsync(startDate, endDate);
|
response.MonthlyTrends = await GetMonthlyTrendsAsync(startDate, endDate);
|
||||||
response.Accounts = await GetAccountSummariesAsync(periodTransactions);
|
response.Accounts = await GetAccountSummariesAsync(periodTransactions);
|
||||||
response.Flags = GenerateAuditFlags(response);
|
response.Flags = GenerateAuditFlags(response);
|
||||||
|
|
||||||
if (includeTransactions)
|
if (includeTransactions)
|
||||||
{
|
{
|
||||||
response.Transactions = await GetTransactionListAsync(periodTransactions);
|
response.Transactions = await GetTransactionListAsync(periodTransactions);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<AuditSummary> CalculateSummaryAsync(
|
private async Task<AuditSummary> CalculateSummaryAsync(
|
||||||
IQueryable<Transaction> transactions,
|
IQueryable<Transaction> transactions,
|
||||||
DateTime startDate,
|
DateTime startDate,
|
||||||
DateTime endDate)
|
DateTime endDate)
|
||||||
{
|
{
|
||||||
// Exclude transfers for spending calculations
|
// Exclude transfers for spending calculations
|
||||||
var nonTransferTxns = transactions.ExcludeTransfers();
|
var nonTransferTxns = transactions.ExcludeTransfers();
|
||||||
|
|
||||||
var stats = await nonTransferTxns
|
var stats = await nonTransferTxns
|
||||||
.GroupBy(_ => 1)
|
.GroupBy(_ => 1)
|
||||||
.Select(g => new
|
.Select(g => new
|
||||||
{
|
{
|
||||||
TotalCount = g.Count(),
|
TotalCount = g.Count(),
|
||||||
TotalIncome = g.Where(t => t.Amount > 0).Sum(t => t.Amount),
|
TotalIncome = g.Where(t => t.Amount > 0).Sum(t => t.Amount),
|
||||||
TotalExpenses = g.Where(t => t.Amount < 0).Sum(t => Math.Abs(t.Amount)),
|
TotalExpenses = g.Where(t => t.Amount < 0).Sum(t => Math.Abs(t.Amount)),
|
||||||
UncategorizedCount = g.Count(t => string.IsNullOrEmpty(t.Category)),
|
UncategorizedCount = g.Count(t => string.IsNullOrEmpty(t.Category)),
|
||||||
UncategorizedAmount = g.Where(t => string.IsNullOrEmpty(t.Category) && t.Amount < 0)
|
UncategorizedAmount = g.Where(t => string.IsNullOrEmpty(t.Category) && t.Amount < 0)
|
||||||
.Sum(t => Math.Abs(t.Amount))
|
.Sum(t => Math.Abs(t.Amount))
|
||||||
})
|
})
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
var daysInPeriod = (endDate.Date - startDate.Date).Days + 1;
|
var daysInPeriod = (endDate.Date - startDate.Date).Days + 1;
|
||||||
|
|
||||||
return new AuditSummary
|
return new AuditSummary
|
||||||
{
|
{
|
||||||
TotalTransactions = stats?.TotalCount ?? 0,
|
TotalTransactions = stats?.TotalCount ?? 0,
|
||||||
TotalIncome = stats?.TotalIncome ?? 0,
|
TotalIncome = stats?.TotalIncome ?? 0,
|
||||||
TotalExpenses = stats?.TotalExpenses ?? 0,
|
TotalExpenses = stats?.TotalExpenses ?? 0,
|
||||||
NetCashFlow = (stats?.TotalIncome ?? 0) - (stats?.TotalExpenses ?? 0),
|
NetCashFlow = (stats?.TotalIncome ?? 0) - (stats?.TotalExpenses ?? 0),
|
||||||
DaysInPeriod = daysInPeriod,
|
DaysInPeriod = daysInPeriod,
|
||||||
AverageDailySpend = daysInPeriod > 0 ? (stats?.TotalExpenses ?? 0) / daysInPeriod : 0,
|
AverageDailySpend = daysInPeriod > 0 ? (stats?.TotalExpenses ?? 0) / daysInPeriod : 0,
|
||||||
UncategorizedTransactions = stats?.UncategorizedCount ?? 0,
|
UncategorizedTransactions = stats?.UncategorizedCount ?? 0,
|
||||||
UncategorizedAmount = stats?.UncategorizedAmount ?? 0
|
UncategorizedAmount = stats?.UncategorizedAmount ?? 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<BudgetStatusDto>> GetBudgetStatusesAsync()
|
private async Task<List<BudgetStatusDto>> GetBudgetStatusesAsync()
|
||||||
{
|
{
|
||||||
var statuses = await _budgetService.GetAllBudgetStatusesAsync();
|
var statuses = await _budgetService.GetAllBudgetStatusesAsync();
|
||||||
|
|
||||||
return statuses.Select(s => new BudgetStatusDto
|
return statuses.Select(s => new BudgetStatusDto
|
||||||
{
|
{
|
||||||
BudgetId = s.Budget.Id,
|
BudgetId = s.Budget.Id,
|
||||||
Category = s.Budget.DisplayName,
|
Category = s.Budget.DisplayName,
|
||||||
Period = s.Budget.Period.ToString(),
|
Period = s.Budget.Period.ToString(),
|
||||||
Limit = s.Budget.Amount,
|
Limit = s.Budget.Amount,
|
||||||
Spent = s.Spent,
|
Spent = s.Spent,
|
||||||
Remaining = s.Remaining,
|
Remaining = s.Remaining,
|
||||||
PercentUsed = s.PercentUsed,
|
PercentUsed = s.PercentUsed,
|
||||||
IsOverBudget = s.IsOverBudget,
|
IsOverBudget = s.IsOverBudget,
|
||||||
PeriodRange = s.PeriodDisplay
|
PeriodRange = s.PeriodDisplay
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<CategorySpendingDto>> GetCategorySpendingAsync(
|
private async Task<List<CategorySpendingDto>> GetCategorySpendingAsync(
|
||||||
IQueryable<Transaction> transactions,
|
IQueryable<Transaction> transactions,
|
||||||
List<BudgetStatusDto> budgets)
|
List<BudgetStatusDto> budgets)
|
||||||
{
|
{
|
||||||
var categorySpending = await transactions
|
var categorySpending = await transactions
|
||||||
.ExcludeTransfers()
|
.ExcludeTransfers()
|
||||||
.Where(t => t.Amount < 0 && !string.IsNullOrEmpty(t.Category))
|
.Where(t => t.Amount < 0 && !string.IsNullOrEmpty(t.Category))
|
||||||
.GroupBy(t => t.Category)
|
.GroupBy(t => t.Category)
|
||||||
.Select(g => new
|
.Select(g => new
|
||||||
{
|
{
|
||||||
Category = g.Key,
|
Category = g.Key,
|
||||||
TotalSpent = g.Sum(t => Math.Abs(t.Amount)),
|
TotalSpent = g.Sum(t => Math.Abs(t.Amount)),
|
||||||
Count = g.Count()
|
Count = g.Count()
|
||||||
})
|
})
|
||||||
.OrderByDescending(x => x.TotalSpent)
|
.OrderByDescending(x => x.TotalSpent)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var totalSpending = categorySpending.Sum(c => c.TotalSpent);
|
var totalSpending = categorySpending.Sum(c => c.TotalSpent);
|
||||||
|
|
||||||
// Create a lookup for budget data by category
|
// Create a lookup for budget data by category
|
||||||
var budgetLookup = budgets
|
var budgetLookup = budgets
|
||||||
.Where(b => b.Category != "Total Spending")
|
.Where(b => b.Category != "Total Spending")
|
||||||
.ToDictionary(b => b.Category.ToLowerInvariant(), b => b);
|
.ToDictionary(b => b.Category.ToLowerInvariant(), b => b);
|
||||||
|
|
||||||
return categorySpending.Select(c =>
|
return categorySpending.Select(c =>
|
||||||
{
|
{
|
||||||
var dto = new CategorySpendingDto
|
var dto = new CategorySpendingDto
|
||||||
{
|
{
|
||||||
Category = c.Category ?? "Uncategorized",
|
Category = c.Category ?? "Uncategorized",
|
||||||
TotalSpent = c.TotalSpent,
|
TotalSpent = c.TotalSpent,
|
||||||
TransactionCount = c.Count,
|
TransactionCount = c.Count,
|
||||||
PercentOfTotal = totalSpending > 0 ? Math.Round(c.TotalSpent / totalSpending * 100, 2) : 0,
|
PercentOfTotal = totalSpending > 0 ? Math.Round(c.TotalSpent / totalSpending * 100, 2) : 0,
|
||||||
AverageTransaction = c.Count > 0 ? Math.Round(c.TotalSpent / c.Count, 2) : 0
|
AverageTransaction = c.Count > 0 ? Math.Round(c.TotalSpent / c.Count, 2) : 0
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add budget correlation if available
|
// Add budget correlation if available
|
||||||
if (budgetLookup.TryGetValue((c.Category ?? "").ToLowerInvariant(), out var budget))
|
if (budgetLookup.TryGetValue((c.Category ?? "").ToLowerInvariant(), out var budget))
|
||||||
{
|
{
|
||||||
dto.BudgetLimit = budget.Limit;
|
dto.BudgetLimit = budget.Limit;
|
||||||
dto.BudgetRemaining = budget.Remaining;
|
dto.BudgetRemaining = budget.Remaining;
|
||||||
dto.IsOverBudget = budget.IsOverBudget;
|
dto.IsOverBudget = budget.IsOverBudget;
|
||||||
}
|
}
|
||||||
|
|
||||||
return dto;
|
return dto;
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<MerchantSpendingDto>> GetMerchantSpendingAsync(
|
private async Task<List<MerchantSpendingDto>> GetMerchantSpendingAsync(
|
||||||
IQueryable<Transaction> transactions)
|
IQueryable<Transaction> transactions)
|
||||||
{
|
{
|
||||||
var merchantSpending = await transactions
|
var merchantSpending = await transactions
|
||||||
.ExcludeTransfers()
|
.ExcludeTransfers()
|
||||||
.Where(t => t.Amount < 0 && t.MerchantId != null)
|
.Where(t => t.Amount < 0 && t.MerchantId != null)
|
||||||
.GroupBy(t => new { t.MerchantId, t.Merchant!.Name })
|
.GroupBy(t => new { t.MerchantId, t.Merchant!.Name })
|
||||||
.Select(g => new
|
.Select(g => new
|
||||||
{
|
{
|
||||||
MerchantName = g.Key.Name,
|
MerchantName = g.Key.Name,
|
||||||
Category = g.Max(t => t.Category),
|
Category = g.Max(t => t.Category),
|
||||||
TotalSpent = g.Sum(t => Math.Abs(t.Amount)),
|
TotalSpent = g.Sum(t => Math.Abs(t.Amount)),
|
||||||
Count = g.Count(),
|
Count = g.Count(),
|
||||||
FirstDate = g.Min(t => t.Date),
|
FirstDate = g.Min(t => t.Date),
|
||||||
LastDate = g.Max(t => t.Date)
|
LastDate = g.Max(t => t.Date)
|
||||||
})
|
})
|
||||||
.OrderByDescending(x => x.TotalSpent)
|
.OrderByDescending(x => x.TotalSpent)
|
||||||
.Take(20)
|
.Take(20)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return merchantSpending.Select(m => new MerchantSpendingDto
|
return merchantSpending.Select(m => new MerchantSpendingDto
|
||||||
{
|
{
|
||||||
MerchantName = m.MerchantName,
|
MerchantName = m.MerchantName,
|
||||||
Category = m.Category,
|
Category = m.Category,
|
||||||
TotalSpent = m.TotalSpent,
|
TotalSpent = m.TotalSpent,
|
||||||
TransactionCount = m.Count,
|
TransactionCount = m.Count,
|
||||||
AverageTransaction = m.Count > 0 ? Math.Round(m.TotalSpent / m.Count, 2) : 0,
|
AverageTransaction = m.Count > 0 ? Math.Round(m.TotalSpent / m.Count, 2) : 0,
|
||||||
FirstTransaction = m.FirstDate,
|
FirstTransaction = m.FirstDate,
|
||||||
LastTransaction = m.LastDate
|
LastTransaction = m.LastDate
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<MonthlyTrendDto>> GetMonthlyTrendsAsync(DateTime startDate, DateTime endDate)
|
private async Task<List<MonthlyTrendDto>> GetMonthlyTrendsAsync(DateTime startDate, DateTime endDate)
|
||||||
{
|
{
|
||||||
var monthlyData = await _db.Transactions
|
var monthlyData = await _db.Transactions
|
||||||
.Where(t => t.Date >= startDate.Date && t.Date <= endDate.Date)
|
.Where(t => t.Date >= startDate.Date && t.Date <= endDate.Date)
|
||||||
.ExcludeTransfers()
|
.ExcludeTransfers()
|
||||||
.GroupBy(t => new { t.Date.Year, t.Date.Month })
|
.GroupBy(t => new { t.Date.Year, t.Date.Month })
|
||||||
.Select(g => new
|
.Select(g => new
|
||||||
{
|
{
|
||||||
g.Key.Year,
|
g.Key.Year,
|
||||||
g.Key.Month,
|
g.Key.Month,
|
||||||
Income = g.Where(t => t.Amount > 0).Sum(t => t.Amount),
|
Income = g.Where(t => t.Amount > 0).Sum(t => t.Amount),
|
||||||
Expenses = g.Where(t => t.Amount < 0).Sum(t => Math.Abs(t.Amount)),
|
Expenses = g.Where(t => t.Amount < 0).Sum(t => Math.Abs(t.Amount)),
|
||||||
Count = g.Count()
|
Count = g.Count()
|
||||||
})
|
})
|
||||||
.OrderBy(x => x.Year)
|
.OrderBy(x => x.Year)
|
||||||
.ThenBy(x => x.Month)
|
.ThenBy(x => x.Month)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
// Get top categories per month
|
// Get top categories per month
|
||||||
var categoryByMonth = await _db.Transactions
|
var categoryByMonth = await _db.Transactions
|
||||||
.Where(t => t.Date >= startDate.Date && t.Date <= endDate.Date)
|
.Where(t => t.Date >= startDate.Date && t.Date <= endDate.Date)
|
||||||
.ExcludeTransfers()
|
.ExcludeTransfers()
|
||||||
.Where(t => t.Amount < 0 && !string.IsNullOrEmpty(t.Category))
|
.Where(t => t.Amount < 0 && !string.IsNullOrEmpty(t.Category))
|
||||||
.GroupBy(t => new { t.Date.Year, t.Date.Month, t.Category })
|
.GroupBy(t => new { t.Date.Year, t.Date.Month, t.Category })
|
||||||
.Select(g => new
|
.Select(g => new
|
||||||
{
|
{
|
||||||
g.Key.Year,
|
g.Key.Year,
|
||||||
g.Key.Month,
|
g.Key.Month,
|
||||||
g.Key.Category,
|
g.Key.Category,
|
||||||
Total = g.Sum(t => Math.Abs(t.Amount))
|
Total = g.Sum(t => Math.Abs(t.Amount))
|
||||||
})
|
})
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return monthlyData.Select(m =>
|
return monthlyData.Select(m =>
|
||||||
{
|
{
|
||||||
var topCategories = categoryByMonth
|
var topCategories = categoryByMonth
|
||||||
.Where(c => c.Year == m.Year && c.Month == m.Month)
|
.Where(c => c.Year == m.Year && c.Month == m.Month)
|
||||||
.OrderByDescending(c => c.Total)
|
.OrderByDescending(c => c.Total)
|
||||||
.Take(5)
|
.Take(5)
|
||||||
.ToDictionary(c => c.Category ?? "Other", c => c.Total);
|
.ToDictionary(c => c.Category ?? "Other", c => c.Total);
|
||||||
|
|
||||||
return new MonthlyTrendDto
|
return new MonthlyTrendDto
|
||||||
{
|
{
|
||||||
Month = $"{m.Year}-{m.Month:D2}",
|
Month = $"{m.Year}-{m.Month:D2}",
|
||||||
Year = m.Year,
|
Year = m.Year,
|
||||||
Income = m.Income,
|
Income = m.Income,
|
||||||
Expenses = m.Expenses,
|
Expenses = m.Expenses,
|
||||||
NetCashFlow = m.Income - m.Expenses,
|
NetCashFlow = m.Income - m.Expenses,
|
||||||
TransactionCount = m.Count,
|
TransactionCount = m.Count,
|
||||||
TopCategories = topCategories
|
TopCategories = topCategories
|
||||||
};
|
};
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<AccountSummaryDto>> GetAccountSummariesAsync(
|
private async Task<List<AccountSummaryDto>> GetAccountSummariesAsync(
|
||||||
IQueryable<Transaction> transactions)
|
IQueryable<Transaction> transactions)
|
||||||
{
|
{
|
||||||
// Use only mapped columns in the GroupBy, compute DisplayLabel in memory
|
// Use only mapped columns in the GroupBy, compute DisplayLabel in memory
|
||||||
var accountStats = await transactions
|
var accountStats = await transactions
|
||||||
.GroupBy(t => new {
|
.GroupBy(t => new {
|
||||||
t.AccountId,
|
t.AccountId,
|
||||||
t.Account.Institution,
|
t.Account.Institution,
|
||||||
t.Account.Last4,
|
t.Account.Last4,
|
||||||
t.Account.Nickname,
|
t.Account.Nickname,
|
||||||
t.Account.AccountType
|
t.Account.AccountType
|
||||||
})
|
})
|
||||||
.Select(g => new
|
.Select(g => new
|
||||||
{
|
{
|
||||||
g.Key.AccountId,
|
g.Key.AccountId,
|
||||||
g.Key.Institution,
|
g.Key.Institution,
|
||||||
g.Key.Last4,
|
g.Key.Last4,
|
||||||
g.Key.Nickname,
|
g.Key.Nickname,
|
||||||
g.Key.AccountType,
|
g.Key.AccountType,
|
||||||
Count = g.Count(),
|
Count = g.Count(),
|
||||||
Debits = g.Where(t => t.Amount < 0).Sum(t => Math.Abs(t.Amount)),
|
Debits = g.Where(t => t.Amount < 0).Sum(t => Math.Abs(t.Amount)),
|
||||||
Credits = g.Where(t => t.Amount > 0).Sum(t => t.Amount)
|
Credits = g.Where(t => t.Amount > 0).Sum(t => t.Amount)
|
||||||
})
|
})
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return accountStats.Select(a => new AccountSummaryDto
|
return accountStats.Select(a => new AccountSummaryDto
|
||||||
{
|
{
|
||||||
AccountId = a.AccountId,
|
AccountId = a.AccountId,
|
||||||
AccountName = string.IsNullOrEmpty(a.Nickname)
|
AccountName = string.IsNullOrEmpty(a.Nickname)
|
||||||
? $"{a.Institution} {a.Last4} ({a.AccountType})"
|
? $"{a.Institution} {a.Last4} ({a.AccountType})"
|
||||||
: $"{a.Nickname} ({a.Institution} {a.Last4})",
|
: $"{a.Nickname} ({a.Institution} {a.Last4})",
|
||||||
Institution = a.Institution,
|
Institution = a.Institution,
|
||||||
AccountType = a.AccountType.ToString(),
|
AccountType = a.AccountType.ToString(),
|
||||||
TransactionCount = a.Count,
|
TransactionCount = a.Count,
|
||||||
TotalDebits = a.Debits,
|
TotalDebits = a.Debits,
|
||||||
TotalCredits = a.Credits,
|
TotalCredits = a.Credits,
|
||||||
NetFlow = a.Credits - a.Debits
|
NetFlow = a.Credits - a.Debits
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<TransactionDto>> GetTransactionListAsync(
|
private async Task<List<TransactionDto>> GetTransactionListAsync(
|
||||||
IQueryable<Transaction> transactions)
|
IQueryable<Transaction> transactions)
|
||||||
{
|
{
|
||||||
// Fetch raw data without computed properties
|
// Fetch raw data without computed properties
|
||||||
var rawTxns = await transactions
|
var rawTxns = await transactions
|
||||||
.OrderByDescending(t => t.Date)
|
.OrderByDescending(t => t.Date)
|
||||||
.ThenByDescending(t => t.Id)
|
.ThenByDescending(t => t.Id)
|
||||||
.Select(t => new
|
.Select(t => new
|
||||||
{
|
{
|
||||||
t.Id,
|
t.Id,
|
||||||
t.Date,
|
t.Date,
|
||||||
t.Name,
|
t.Name,
|
||||||
t.Memo,
|
t.Memo,
|
||||||
t.Amount,
|
t.Amount,
|
||||||
t.Category,
|
t.Category,
|
||||||
MerchantName = t.Merchant != null ? t.Merchant.Name : null,
|
MerchantName = t.Merchant != null ? t.Merchant.Name : null,
|
||||||
AccountInstitution = t.Account.Institution,
|
AccountInstitution = t.Account.Institution,
|
||||||
AccountLast4 = t.Account.Last4,
|
AccountLast4 = t.Account.Last4,
|
||||||
AccountNickname = t.Account.Nickname,
|
AccountNickname = t.Account.Nickname,
|
||||||
AccountType = t.Account.AccountType,
|
AccountType = t.Account.AccountType,
|
||||||
CardIssuer = t.Card != null ? t.Card.Issuer : null,
|
CardIssuer = t.Card != null ? t.Card.Issuer : null,
|
||||||
CardLast4 = t.Card != null ? t.Card.Last4 : null,
|
CardLast4 = t.Card != null ? t.Card.Last4 : null,
|
||||||
CardNickname = t.Card != null ? t.Card.Nickname : null,
|
CardNickname = t.Card != null ? t.Card.Nickname : null,
|
||||||
IsTransfer = t.TransferToAccountId != null
|
IsTransfer = t.TransferToAccountId != null
|
||||||
})
|
})
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
// Map to DTOs with computed labels
|
// Map to DTOs with computed labels
|
||||||
return rawTxns.Select(t => new TransactionDto
|
return rawTxns.Select(t => new TransactionDto
|
||||||
{
|
{
|
||||||
Id = t.Id,
|
Id = t.Id,
|
||||||
Date = t.Date,
|
Date = t.Date,
|
||||||
Name = t.Name,
|
Name = t.Name,
|
||||||
Memo = t.Memo,
|
Memo = t.Memo,
|
||||||
Amount = t.Amount,
|
Amount = t.Amount,
|
||||||
Category = t.Category,
|
Category = t.Category,
|
||||||
MerchantName = t.MerchantName,
|
MerchantName = t.MerchantName,
|
||||||
AccountName = string.IsNullOrEmpty(t.AccountNickname)
|
AccountName = string.IsNullOrEmpty(t.AccountNickname)
|
||||||
? $"{t.AccountInstitution} {t.AccountLast4} ({t.AccountType})"
|
? $"{t.AccountInstitution} {t.AccountLast4} ({t.AccountType})"
|
||||||
: $"{t.AccountNickname} ({t.AccountInstitution} {t.AccountLast4})",
|
: $"{t.AccountNickname} ({t.AccountInstitution} {t.AccountLast4})",
|
||||||
CardLabel = t.CardIssuer != null
|
CardLabel = t.CardIssuer != null
|
||||||
? (string.IsNullOrEmpty(t.CardNickname)
|
? (string.IsNullOrEmpty(t.CardNickname)
|
||||||
? $"{t.CardIssuer} {t.CardLast4}"
|
? $"{t.CardIssuer} {t.CardLast4}"
|
||||||
: $"{t.CardNickname} ({t.CardIssuer} {t.CardLast4})")
|
: $"{t.CardNickname} ({t.CardIssuer} {t.CardLast4})")
|
||||||
: null,
|
: null,
|
||||||
IsTransfer = t.IsTransfer
|
IsTransfer = t.IsTransfer
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<AuditFlagDto> GenerateAuditFlags(FinancialAuditResponse response)
|
private List<AuditFlagDto> GenerateAuditFlags(FinancialAuditResponse response)
|
||||||
{
|
{
|
||||||
var flags = new List<AuditFlagDto>();
|
var flags = new List<AuditFlagDto>();
|
||||||
|
|
||||||
// Flag: Over-budget categories
|
// Flag: Over-budget categories
|
||||||
foreach (var budget in response.Budgets.Where(b => b.IsOverBudget))
|
foreach (var budget in response.Budgets.Where(b => b.IsOverBudget))
|
||||||
{
|
{
|
||||||
var overBy = budget.Spent - budget.Limit;
|
var overBy = budget.Spent - budget.Limit;
|
||||||
flags.Add(new AuditFlagDto
|
flags.Add(new AuditFlagDto
|
||||||
{
|
{
|
||||||
Type = "OverBudget",
|
Type = "OverBudget",
|
||||||
Severity = "Alert",
|
Severity = "Alert",
|
||||||
Message = $"{budget.Category} budget exceeded by {overBy:C} ({budget.PercentUsed:F0}% of {budget.Limit:C} limit)",
|
Message = $"{budget.Category} budget exceeded by {overBy:C} ({budget.PercentUsed:F0}% of {budget.Limit:C} limit)",
|
||||||
Details = new
|
Details = new
|
||||||
{
|
{
|
||||||
budget.BudgetId,
|
budget.BudgetId,
|
||||||
budget.Category,
|
budget.Category,
|
||||||
budget.Limit,
|
budget.Limit,
|
||||||
budget.Spent,
|
budget.Spent,
|
||||||
OverAmount = overBy,
|
OverAmount = overBy,
|
||||||
budget.PercentUsed
|
budget.PercentUsed
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flag: High budget utilization (>80% but not over)
|
// Flag: High budget utilization (>80% but not over)
|
||||||
foreach (var budget in response.Budgets.Where(b => !b.IsOverBudget && b.PercentUsed >= 80))
|
foreach (var budget in response.Budgets.Where(b => !b.IsOverBudget && b.PercentUsed >= 80))
|
||||||
{
|
{
|
||||||
flags.Add(new AuditFlagDto
|
flags.Add(new AuditFlagDto
|
||||||
{
|
{
|
||||||
Type = "HighBudgetUtilization",
|
Type = "HighBudgetUtilization",
|
||||||
Severity = "Warning",
|
Severity = "Warning",
|
||||||
Message = $"{budget.Category} budget at {budget.PercentUsed:F0}% ({budget.Remaining:C} remaining)",
|
Message = $"{budget.Category} budget at {budget.PercentUsed:F0}% ({budget.Remaining:C} remaining)",
|
||||||
Details = new
|
Details = new
|
||||||
{
|
{
|
||||||
budget.BudgetId,
|
budget.BudgetId,
|
||||||
budget.Category,
|
budget.Category,
|
||||||
budget.Limit,
|
budget.Limit,
|
||||||
budget.Spent,
|
budget.Spent,
|
||||||
budget.Remaining,
|
budget.Remaining,
|
||||||
budget.PercentUsed
|
budget.PercentUsed
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flag: Uncategorized transactions
|
// Flag: Uncategorized transactions
|
||||||
if (response.Summary.UncategorizedTransactions > 0)
|
if (response.Summary.UncategorizedTransactions > 0)
|
||||||
{
|
{
|
||||||
flags.Add(new AuditFlagDto
|
flags.Add(new AuditFlagDto
|
||||||
{
|
{
|
||||||
Type = "Uncategorized",
|
Type = "Uncategorized",
|
||||||
Severity = "Warning",
|
Severity = "Warning",
|
||||||
Message = $"{response.Summary.UncategorizedTransactions} transactions ({response.Summary.UncategorizedAmount:C}) are uncategorized",
|
Message = $"{response.Summary.UncategorizedTransactions} transactions ({response.Summary.UncategorizedAmount:C}) are uncategorized",
|
||||||
Details = new
|
Details = new
|
||||||
{
|
{
|
||||||
Count = response.Summary.UncategorizedTransactions,
|
Count = response.Summary.UncategorizedTransactions,
|
||||||
Amount = response.Summary.UncategorizedAmount
|
Amount = response.Summary.UncategorizedAmount
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flag: Negative net cash flow
|
// Flag: Negative net cash flow
|
||||||
if (response.Summary.NetCashFlow < 0)
|
if (response.Summary.NetCashFlow < 0)
|
||||||
{
|
{
|
||||||
flags.Add(new AuditFlagDto
|
flags.Add(new AuditFlagDto
|
||||||
{
|
{
|
||||||
Type = "NegativeCashFlow",
|
Type = "NegativeCashFlow",
|
||||||
Severity = "Alert",
|
Severity = "Alert",
|
||||||
Message = $"Spending exceeded income by {Math.Abs(response.Summary.NetCashFlow):C} during this period",
|
Message = $"Spending exceeded income by {Math.Abs(response.Summary.NetCashFlow):C} during this period",
|
||||||
Details = new
|
Details = new
|
||||||
{
|
{
|
||||||
response.Summary.TotalIncome,
|
response.Summary.TotalIncome,
|
||||||
response.Summary.TotalExpenses,
|
response.Summary.TotalExpenses,
|
||||||
response.Summary.NetCashFlow
|
response.Summary.NetCashFlow
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flag: Large single category spending (>30% of total)
|
// Flag: Large single category spending (>30% of total)
|
||||||
foreach (var category in response.SpendingByCategory.Where(c => c.PercentOfTotal > 30))
|
foreach (var category in response.SpendingByCategory.Where(c => c.PercentOfTotal > 30))
|
||||||
{
|
{
|
||||||
flags.Add(new AuditFlagDto
|
flags.Add(new AuditFlagDto
|
||||||
{
|
{
|
||||||
Type = "HighCategoryConcentration",
|
Type = "HighCategoryConcentration",
|
||||||
Severity = "Info",
|
Severity = "Info",
|
||||||
Message = $"{category.Category} accounts for {category.PercentOfTotal:F0}% of total spending ({category.TotalSpent:C})",
|
Message = $"{category.Category} accounts for {category.PercentOfTotal:F0}% of total spending ({category.TotalSpent:C})",
|
||||||
Details = new
|
Details = new
|
||||||
{
|
{
|
||||||
category.Category,
|
category.Category,
|
||||||
category.TotalSpent,
|
category.TotalSpent,
|
||||||
category.PercentOfTotal,
|
category.PercentOfTotal,
|
||||||
category.TransactionCount
|
category.TransactionCount
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flag: Month-over-month spending increases
|
// Flag: Month-over-month spending increases
|
||||||
if (response.MonthlyTrends.Count >= 2)
|
if (response.MonthlyTrends.Count >= 2)
|
||||||
{
|
{
|
||||||
var recentMonths = response.MonthlyTrends.TakeLast(2).ToList();
|
var recentMonths = response.MonthlyTrends.TakeLast(2).ToList();
|
||||||
var previousMonth = recentMonths[0];
|
var previousMonth = recentMonths[0];
|
||||||
var currentMonth = recentMonths[1];
|
var currentMonth = recentMonths[1];
|
||||||
|
|
||||||
if (previousMonth.Expenses > 0)
|
if (previousMonth.Expenses > 0)
|
||||||
{
|
{
|
||||||
var percentChange = (currentMonth.Expenses - previousMonth.Expenses) / previousMonth.Expenses * 100;
|
var percentChange = (currentMonth.Expenses - previousMonth.Expenses) / previousMonth.Expenses * 100;
|
||||||
if (percentChange > 20)
|
if (percentChange > 20)
|
||||||
{
|
{
|
||||||
flags.Add(new AuditFlagDto
|
flags.Add(new AuditFlagDto
|
||||||
{
|
{
|
||||||
Type = "SpendingIncrease",
|
Type = "SpendingIncrease",
|
||||||
Severity = "Warning",
|
Severity = "Warning",
|
||||||
Message = $"Spending increased {percentChange:F0}% from {previousMonth.Month} ({previousMonth.Expenses:C}) to {currentMonth.Month} ({currentMonth.Expenses:C})",
|
Message = $"Spending increased {percentChange:F0}% from {previousMonth.Month} ({previousMonth.Expenses:C}) to {currentMonth.Month} ({currentMonth.Expenses:C})",
|
||||||
Details = new
|
Details = new
|
||||||
{
|
{
|
||||||
PreviousMonth = previousMonth.Month,
|
PreviousMonth = previousMonth.Month,
|
||||||
PreviousExpenses = previousMonth.Expenses,
|
PreviousExpenses = previousMonth.Expenses,
|
||||||
CurrentMonth = currentMonth.Month,
|
CurrentMonth = currentMonth.Month,
|
||||||
CurrentExpenses = currentMonth.Expenses,
|
CurrentExpenses = currentMonth.Expenses,
|
||||||
PercentChange = percentChange
|
PercentChange = percentChange
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flag: Categories without budgets (top spending categories)
|
// Flag: Categories without budgets (top spending categories)
|
||||||
var topUnbudgetedCategories = response.SpendingByCategory
|
var topUnbudgetedCategories = response.SpendingByCategory
|
||||||
.Where(c => c.BudgetLimit == null && c.TotalSpent > 100)
|
.Where(c => c.BudgetLimit == null && c.TotalSpent > 100)
|
||||||
.Take(3)
|
.Take(3)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (topUnbudgetedCategories.Any())
|
if (topUnbudgetedCategories.Any())
|
||||||
{
|
{
|
||||||
flags.Add(new AuditFlagDto
|
flags.Add(new AuditFlagDto
|
||||||
{
|
{
|
||||||
Type = "NoBudget",
|
Type = "NoBudget",
|
||||||
Severity = "Info",
|
Severity = "Info",
|
||||||
Message = $"Top spending categories without budgets: {string.Join(", ", topUnbudgetedCategories.Select(c => $"{c.Category} ({c.TotalSpent:C})"))}",
|
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()
|
Details = topUnbudgetedCategories.Select(c => new { c.Category, c.TotalSpent }).ToList()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return flags.OrderByDescending(f => f.Severity switch
|
return flags.OrderByDescending(f => f.Severity switch
|
||||||
{
|
{
|
||||||
"Alert" => 3,
|
"Alert" => 3,
|
||||||
"Warning" => 2,
|
"Warning" => 2,
|
||||||
"Info" => 1,
|
"Info" => 1,
|
||||||
_ => 0
|
_ => 0
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
namespace MoneyMap.Services;
|
namespace MoneyMap.Services;
|
||||||
|
|
||||||
public interface IReceiptStorageOptions
|
public interface IReceiptStorageOptions
|
||||||
{
|
{
|
||||||
string ReceiptsBasePath { get; }
|
string ReceiptsBasePath { get; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,184 +1,184 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using MoneyMap.Data;
|
using MoneyMap.Data;
|
||||||
using MoneyMap.Models;
|
using MoneyMap.Models;
|
||||||
|
|
||||||
namespace MoneyMap.Services
|
namespace MoneyMap.Services
|
||||||
{
|
{
|
||||||
public interface IMerchantService
|
public interface IMerchantService
|
||||||
{
|
{
|
||||||
Task<Merchant?> FindByNameAsync(string name);
|
Task<Merchant?> FindByNameAsync(string name);
|
||||||
Task<Merchant> GetOrCreateAsync(string name);
|
Task<Merchant> GetOrCreateAsync(string name);
|
||||||
Task<int?> GetOrCreateIdAsync(string? name);
|
Task<int?> GetOrCreateIdAsync(string? name);
|
||||||
Task<Merchant?> GetMerchantByIdAsync(int id, bool includeRelated = false);
|
Task<Merchant?> GetMerchantByIdAsync(int id, bool includeRelated = false);
|
||||||
Task<List<MerchantWithStats>> GetAllMerchantsWithStatsAsync();
|
Task<List<MerchantWithStats>> GetAllMerchantsWithStatsAsync();
|
||||||
Task<MerchantUpdateResult> UpdateMerchantAsync(int id, string newName);
|
Task<MerchantUpdateResult> UpdateMerchantAsync(int id, string newName);
|
||||||
Task<MerchantDeleteResult> DeleteMerchantAsync(int id);
|
Task<MerchantDeleteResult> DeleteMerchantAsync(int id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class MerchantService : IMerchantService
|
public class MerchantService : IMerchantService
|
||||||
{
|
{
|
||||||
private readonly MoneyMapContext _db;
|
private readonly MoneyMapContext _db;
|
||||||
|
|
||||||
public MerchantService(MoneyMapContext db)
|
public MerchantService(MoneyMapContext db)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Merchant?> FindByNameAsync(string name)
|
public async Task<Merchant?> FindByNameAsync(string name)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(name))
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
return await _db.Merchants
|
return await _db.Merchants
|
||||||
.FirstOrDefaultAsync(m => m.Name == name.Trim());
|
.FirstOrDefaultAsync(m => m.Name == name.Trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Merchant> GetOrCreateAsync(string name)
|
public async Task<Merchant> GetOrCreateAsync(string name)
|
||||||
{
|
{
|
||||||
var trimmedName = name.Trim();
|
var trimmedName = name.Trim();
|
||||||
|
|
||||||
var existing = await _db.Merchants
|
var existing = await _db.Merchants
|
||||||
.FirstOrDefaultAsync(m => m.Name == trimmedName);
|
.FirstOrDefaultAsync(m => m.Name == trimmedName);
|
||||||
|
|
||||||
if (existing != null)
|
if (existing != null)
|
||||||
return existing;
|
return existing;
|
||||||
|
|
||||||
var merchant = new Merchant { Name = trimmedName };
|
var merchant = new Merchant { Name = trimmedName };
|
||||||
_db.Merchants.Add(merchant);
|
_db.Merchants.Add(merchant);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return merchant;
|
return merchant;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int?> GetOrCreateIdAsync(string? name)
|
public async Task<int?> GetOrCreateIdAsync(string? name)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(name))
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var merchant = await GetOrCreateAsync(name);
|
var merchant = await GetOrCreateAsync(name);
|
||||||
return merchant.Id;
|
return merchant.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Merchant?> GetMerchantByIdAsync(int id, bool includeRelated = false)
|
public async Task<Merchant?> GetMerchantByIdAsync(int id, bool includeRelated = false)
|
||||||
{
|
{
|
||||||
var query = _db.Merchants.AsQueryable();
|
var query = _db.Merchants.AsQueryable();
|
||||||
|
|
||||||
if (includeRelated)
|
if (includeRelated)
|
||||||
{
|
{
|
||||||
query = query
|
query = query
|
||||||
.Include(m => m.Transactions)
|
.Include(m => m.Transactions)
|
||||||
.Include(m => m.CategoryMappings);
|
.Include(m => m.CategoryMappings);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await query.FirstOrDefaultAsync(m => m.Id == id);
|
return await query.FirstOrDefaultAsync(m => m.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<MerchantWithStats>> GetAllMerchantsWithStatsAsync()
|
public async Task<List<MerchantWithStats>> GetAllMerchantsWithStatsAsync()
|
||||||
{
|
{
|
||||||
var merchants = await _db.Merchants
|
var merchants = await _db.Merchants
|
||||||
.Include(m => m.Transactions)
|
.Include(m => m.Transactions)
|
||||||
.Include(m => m.CategoryMappings)
|
.Include(m => m.CategoryMappings)
|
||||||
.OrderBy(m => m.Name)
|
.OrderBy(m => m.Name)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return merchants.Select(m => new MerchantWithStats
|
return merchants.Select(m => new MerchantWithStats
|
||||||
{
|
{
|
||||||
Id = m.Id,
|
Id = m.Id,
|
||||||
Name = m.Name,
|
Name = m.Name,
|
||||||
TransactionCount = m.Transactions.Count,
|
TransactionCount = m.Transactions.Count,
|
||||||
MappingCount = m.CategoryMappings.Count
|
MappingCount = m.CategoryMappings.Count
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<MerchantUpdateResult> UpdateMerchantAsync(int id, string newName)
|
public async Task<MerchantUpdateResult> UpdateMerchantAsync(int id, string newName)
|
||||||
{
|
{
|
||||||
var merchant = await _db.Merchants.FindAsync(id);
|
var merchant = await _db.Merchants.FindAsync(id);
|
||||||
if (merchant == null)
|
if (merchant == null)
|
||||||
{
|
{
|
||||||
return new MerchantUpdateResult
|
return new MerchantUpdateResult
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "Merchant not found."
|
Message = "Merchant not found."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
var trimmedName = newName.Trim();
|
var trimmedName = newName.Trim();
|
||||||
|
|
||||||
// Check if another merchant with the same name exists
|
// Check if another merchant with the same name exists
|
||||||
var existing = await _db.Merchants
|
var existing = await _db.Merchants
|
||||||
.FirstOrDefaultAsync(m => m.Name == trimmedName && m.Id != id);
|
.FirstOrDefaultAsync(m => m.Name == trimmedName && m.Id != id);
|
||||||
|
|
||||||
if (existing != null)
|
if (existing != null)
|
||||||
{
|
{
|
||||||
return new MerchantUpdateResult
|
return new MerchantUpdateResult
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = $"Merchant '{trimmedName}' already exists."
|
Message = $"Merchant '{trimmedName}' already exists."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
merchant.Name = trimmedName;
|
merchant.Name = trimmedName;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return new MerchantUpdateResult
|
return new MerchantUpdateResult
|
||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
Message = "Merchant updated successfully."
|
Message = "Merchant updated successfully."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<MerchantDeleteResult> DeleteMerchantAsync(int id)
|
public async Task<MerchantDeleteResult> DeleteMerchantAsync(int id)
|
||||||
{
|
{
|
||||||
var merchant = await _db.Merchants
|
var merchant = await _db.Merchants
|
||||||
.Include(m => m.Transactions)
|
.Include(m => m.Transactions)
|
||||||
.Include(m => m.CategoryMappings)
|
.Include(m => m.CategoryMappings)
|
||||||
.FirstOrDefaultAsync(m => m.Id == id);
|
.FirstOrDefaultAsync(m => m.Id == id);
|
||||||
|
|
||||||
if (merchant == null)
|
if (merchant == null)
|
||||||
{
|
{
|
||||||
return new MerchantDeleteResult
|
return new MerchantDeleteResult
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "Merchant not found."
|
Message = "Merchant not found."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
var transactionCount = merchant.Transactions.Count;
|
var transactionCount = merchant.Transactions.Count;
|
||||||
var mappingCount = merchant.CategoryMappings.Count;
|
var mappingCount = merchant.CategoryMappings.Count;
|
||||||
|
|
||||||
_db.Merchants.Remove(merchant);
|
_db.Merchants.Remove(merchant);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return new MerchantDeleteResult
|
return new MerchantDeleteResult
|
||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
Message = $"Deleted merchant '{merchant.Name}'. {transactionCount} transactions and {mappingCount} category mappings are now unlinked.",
|
Message = $"Deleted merchant '{merchant.Name}'. {transactionCount} transactions and {mappingCount} category mappings are now unlinked.",
|
||||||
TransactionCount = transactionCount,
|
TransactionCount = transactionCount,
|
||||||
MappingCount = mappingCount
|
MappingCount = mappingCount
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DTOs
|
// DTOs
|
||||||
public class MerchantWithStats
|
public class MerchantWithStats
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
public int TransactionCount { get; set; }
|
public int TransactionCount { get; set; }
|
||||||
public int MappingCount { get; set; }
|
public int MappingCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class MerchantUpdateResult
|
public class MerchantUpdateResult
|
||||||
{
|
{
|
||||||
public bool Success { get; set; }
|
public bool Success { get; set; }
|
||||||
public string Message { get; set; } = "";
|
public string Message { get; set; } = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
public class MerchantDeleteResult
|
public class MerchantDeleteResult
|
||||||
{
|
{
|
||||||
public bool Success { get; set; }
|
public bool Success { get; set; }
|
||||||
public string Message { get; set; } = "";
|
public string Message { get; set; } = "";
|
||||||
public int TransactionCount { get; set; }
|
public int TransactionCount { get; set; }
|
||||||
public int MappingCount { get; set; }
|
public int MappingCount { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +1,61 @@
|
|||||||
using ImageMagick;
|
using ImageMagick;
|
||||||
|
|
||||||
namespace MoneyMap.Services
|
namespace MoneyMap.Services
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Service for converting PDF files to images for AI processing.
|
/// Service for converting PDF files to images for AI processing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IPdfToImageConverter
|
public interface IPdfToImageConverter
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Converts the first page of a PDF to a base64-encoded PNG image.
|
/// Converts the first page of a PDF to a base64-encoded PNG image.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<string> ConvertFirstPageToBase64Async(string pdfPath);
|
Task<string> ConvertFirstPageToBase64Async(string pdfPath);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Converts PDF bytes to a base64-encoded PNG image.
|
/// Converts PDF bytes to a base64-encoded PNG image.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<string> ConvertFirstPageToBase64Async(byte[] pdfBytes);
|
Task<string> ConvertFirstPageToBase64Async(byte[] pdfBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PdfToImageConverter : IPdfToImageConverter
|
public class PdfToImageConverter : IPdfToImageConverter
|
||||||
{
|
{
|
||||||
private const int DefaultDpi = 220;
|
private const int DefaultDpi = 220;
|
||||||
|
|
||||||
public Task<string> ConvertFirstPageToBase64Async(string pdfPath)
|
public Task<string> ConvertFirstPageToBase64Async(string pdfPath)
|
||||||
{
|
{
|
||||||
var pdfBytes = File.ReadAllBytes(pdfPath);
|
var pdfBytes = File.ReadAllBytes(pdfPath);
|
||||||
return ConvertFirstPageToBase64Async(pdfBytes);
|
return ConvertFirstPageToBase64Async(pdfBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<string> ConvertFirstPageToBase64Async(byte[] pdfBytes)
|
public Task<string> ConvertFirstPageToBase64Async(byte[] pdfBytes)
|
||||||
{
|
{
|
||||||
return Task.Run(() =>
|
return Task.Run(() =>
|
||||||
{
|
{
|
||||||
var settings = new MagickReadSettings
|
var settings = new MagickReadSettings
|
||||||
{
|
{
|
||||||
Density = new Density(DefaultDpi),
|
Density = new Density(DefaultDpi),
|
||||||
BackgroundColor = MagickColors.White,
|
BackgroundColor = MagickColors.White,
|
||||||
ColorSpace = ColorSpace.sRGB
|
ColorSpace = ColorSpace.sRGB
|
||||||
};
|
};
|
||||||
|
|
||||||
using var pages = new MagickImageCollection();
|
using var pages = new MagickImageCollection();
|
||||||
pages.Read(pdfBytes, settings);
|
pages.Read(pdfBytes, settings);
|
||||||
|
|
||||||
if (pages.Count == 0)
|
if (pages.Count == 0)
|
||||||
throw new InvalidOperationException("PDF has no pages");
|
throw new InvalidOperationException("PDF has no pages");
|
||||||
|
|
||||||
using var img = (MagickImage)pages[0].Clone();
|
using var img = (MagickImage)pages[0].Clone();
|
||||||
|
|
||||||
// Ensure we have a clean 8-bit RGB canvas
|
// Ensure we have a clean 8-bit RGB canvas
|
||||||
img.ColorType = ColorType.TrueColor;
|
img.ColorType = ColorType.TrueColor;
|
||||||
img.Alpha(AlphaOption.Remove); // flatten onto white
|
img.Alpha(AlphaOption.Remove); // flatten onto white
|
||||||
img.ResetPage();
|
img.ResetPage();
|
||||||
|
|
||||||
// Convert to PNG bytes
|
// Convert to PNG bytes
|
||||||
var imageBytes = img.ToByteArray(MagickFormat.Png);
|
var imageBytes = img.ToByteArray(MagickFormat.Png);
|
||||||
return Convert.ToBase64String(imageBytes);
|
return Convert.ToBase64String(imageBytes);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,495 +1,495 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using MoneyMap.Data;
|
using MoneyMap.Data;
|
||||||
using MoneyMap.Models;
|
using MoneyMap.Models;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace MoneyMap.Services
|
namespace MoneyMap.Services
|
||||||
{
|
{
|
||||||
public interface IReceiptAutoMapper
|
public interface IReceiptAutoMapper
|
||||||
{
|
{
|
||||||
Task<ReceiptAutoMapResult> AutoMapReceiptAsync(long receiptId);
|
Task<ReceiptAutoMapResult> AutoMapReceiptAsync(long receiptId);
|
||||||
Task<BulkAutoMapResult> AutoMapUnmappedReceiptsAsync();
|
Task<BulkAutoMapResult> AutoMapUnmappedReceiptsAsync();
|
||||||
Task<List<ScoredCandidate>> GetScoredCandidatesAsync(long receiptId);
|
Task<List<ScoredCandidate>> GetScoredCandidatesAsync(long receiptId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ReceiptAutoMapper : IReceiptAutoMapper
|
public class ReceiptAutoMapper : IReceiptAutoMapper
|
||||||
{
|
{
|
||||||
private readonly MoneyMapContext _db;
|
private readonly MoneyMapContext _db;
|
||||||
private readonly IReceiptManager _receiptManager;
|
private readonly IReceiptManager _receiptManager;
|
||||||
private readonly LlamaCppVisionClient _llmClient;
|
private readonly LlamaCppVisionClient _llmClient;
|
||||||
private readonly ILogger<ReceiptAutoMapper> _logger;
|
private readonly ILogger<ReceiptAutoMapper> _logger;
|
||||||
|
|
||||||
// Confidence thresholds
|
// Confidence thresholds
|
||||||
private const double AutoMapThreshold = 0.85; // Auto-map if score >= 85%
|
private const double AutoMapThreshold = 0.85; // Auto-map if score >= 85%
|
||||||
private const double LlmReviewThreshold = 0.50; // Use LLM if score between 50-85%
|
private const double LlmReviewThreshold = 0.50; // Use LLM if score between 50-85%
|
||||||
|
|
||||||
public ReceiptAutoMapper(
|
public ReceiptAutoMapper(
|
||||||
MoneyMapContext db,
|
MoneyMapContext db,
|
||||||
IReceiptManager receiptManager,
|
IReceiptManager receiptManager,
|
||||||
LlamaCppVisionClient llmClient,
|
LlamaCppVisionClient llmClient,
|
||||||
ILogger<ReceiptAutoMapper> logger)
|
ILogger<ReceiptAutoMapper> logger)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_receiptManager = receiptManager;
|
_receiptManager = receiptManager;
|
||||||
_llmClient = llmClient;
|
_llmClient = llmClient;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ReceiptAutoMapResult> AutoMapReceiptAsync(long receiptId)
|
public async Task<ReceiptAutoMapResult> AutoMapReceiptAsync(long receiptId)
|
||||||
{
|
{
|
||||||
var receipt = await _db.Receipts
|
var receipt = await _db.Receipts
|
||||||
.Include(r => r.Transaction)
|
.Include(r => r.Transaction)
|
||||||
.FirstOrDefaultAsync(r => r.Id == receiptId);
|
.FirstOrDefaultAsync(r => r.Id == receiptId);
|
||||||
|
|
||||||
if (receipt == null)
|
if (receipt == null)
|
||||||
return ReceiptAutoMapResult.Failure("Receipt not found.");
|
return ReceiptAutoMapResult.Failure("Receipt not found.");
|
||||||
|
|
||||||
if (receipt.TransactionId.HasValue)
|
if (receipt.TransactionId.HasValue)
|
||||||
return ReceiptAutoMapResult.AlreadyMapped(receipt.TransactionId.Value);
|
return ReceiptAutoMapResult.AlreadyMapped(receipt.TransactionId.Value);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(receipt.Merchant) && !receipt.ReceiptDate.HasValue && !receipt.Total.HasValue)
|
if (string.IsNullOrWhiteSpace(receipt.Merchant) && !receipt.ReceiptDate.HasValue && !receipt.Total.HasValue)
|
||||||
return ReceiptAutoMapResult.NotParsed();
|
return ReceiptAutoMapResult.NotParsed();
|
||||||
|
|
||||||
var scoredCandidates = await FindAndScoreCandidatesAsync(receipt);
|
var scoredCandidates = await FindAndScoreCandidatesAsync(receipt);
|
||||||
|
|
||||||
if (scoredCandidates.Count == 0)
|
if (scoredCandidates.Count == 0)
|
||||||
return ReceiptAutoMapResult.NoMatch();
|
return ReceiptAutoMapResult.NoMatch();
|
||||||
|
|
||||||
var bestMatch = scoredCandidates[0];
|
var bestMatch = scoredCandidates[0];
|
||||||
|
|
||||||
// High confidence - auto-map directly
|
// High confidence - auto-map directly
|
||||||
if (bestMatch.Score >= AutoMapThreshold)
|
if (bestMatch.Score >= AutoMapThreshold)
|
||||||
{
|
{
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Auto-mapping receipt {ReceiptId} to transaction {TransactionId} with score {Score:P0}",
|
"Auto-mapping receipt {ReceiptId} to transaction {TransactionId} with score {Score:P0}",
|
||||||
receiptId, bestMatch.Transaction.Id, bestMatch.Score);
|
receiptId, bestMatch.Transaction.Id, bestMatch.Score);
|
||||||
|
|
||||||
var success = await _receiptManager.MapReceiptToTransactionAsync(receiptId, bestMatch.Transaction.Id);
|
var success = await _receiptManager.MapReceiptToTransactionAsync(receiptId, bestMatch.Transaction.Id);
|
||||||
return success
|
return success
|
||||||
? ReceiptAutoMapResult.Success(bestMatch.Transaction.Id)
|
? ReceiptAutoMapResult.Success(bestMatch.Transaction.Id)
|
||||||
: ReceiptAutoMapResult.Failure("Failed to map receipt to transaction.");
|
: ReceiptAutoMapResult.Failure("Failed to map receipt to transaction.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Medium confidence - use LLM to decide
|
// Medium confidence - use LLM to decide
|
||||||
if (bestMatch.Score >= LlmReviewThreshold)
|
if (bestMatch.Score >= LlmReviewThreshold)
|
||||||
{
|
{
|
||||||
var topCandidates = scoredCandidates.Take(5).ToList();
|
var topCandidates = scoredCandidates.Take(5).ToList();
|
||||||
var llmResult = await GetLlmMatchDecisionAsync(receipt, topCandidates);
|
var llmResult = await GetLlmMatchDecisionAsync(receipt, topCandidates);
|
||||||
|
|
||||||
if (llmResult != null && llmResult.Confidence >= 0.7)
|
if (llmResult != null && llmResult.Confidence >= 0.7)
|
||||||
{
|
{
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"LLM matched receipt {ReceiptId} to transaction {TransactionId} with confidence {Confidence:P0}",
|
"LLM matched receipt {ReceiptId} to transaction {TransactionId} with confidence {Confidence:P0}",
|
||||||
receiptId, llmResult.TransactionId, llmResult.Confidence);
|
receiptId, llmResult.TransactionId, llmResult.Confidence);
|
||||||
|
|
||||||
var success = await _receiptManager.MapReceiptToTransactionAsync(receiptId, llmResult.TransactionId);
|
var success = await _receiptManager.MapReceiptToTransactionAsync(receiptId, llmResult.TransactionId);
|
||||||
return success
|
return success
|
||||||
? ReceiptAutoMapResult.Success(llmResult.TransactionId)
|
? ReceiptAutoMapResult.Success(llmResult.TransactionId)
|
||||||
: ReceiptAutoMapResult.Failure("Failed to map receipt to transaction.");
|
: ReceiptAutoMapResult.Failure("Failed to map receipt to transaction.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// LLM uncertain - return multiple matches for manual review
|
// LLM uncertain - return multiple matches for manual review
|
||||||
return ReceiptAutoMapResult.WithMultipleMatches(
|
return ReceiptAutoMapResult.WithMultipleMatches(
|
||||||
topCandidates.Select(c => c.Transaction).ToList());
|
topCandidates.Select(c => c.Transaction).ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Low confidence - no good matches
|
// Low confidence - no good matches
|
||||||
if (scoredCandidates.Count > 1)
|
if (scoredCandidates.Count > 1)
|
||||||
{
|
{
|
||||||
return ReceiptAutoMapResult.WithMultipleMatches(
|
return ReceiptAutoMapResult.WithMultipleMatches(
|
||||||
scoredCandidates.Take(5).Select(c => c.Transaction).ToList());
|
scoredCandidates.Take(5).Select(c => c.Transaction).ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
return ReceiptAutoMapResult.NoMatch();
|
return ReceiptAutoMapResult.NoMatch();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<BulkAutoMapResult> AutoMapUnmappedReceiptsAsync()
|
public async Task<BulkAutoMapResult> AutoMapUnmappedReceiptsAsync()
|
||||||
{
|
{
|
||||||
var unmappedReceipts = await _db.Receipts
|
var unmappedReceipts = await _db.Receipts
|
||||||
.Where(r => r.TransactionId == null)
|
.Where(r => r.TransactionId == null)
|
||||||
.Where(r => r.Merchant != null || r.ReceiptDate != null || r.Total != null)
|
.Where(r => r.Merchant != null || r.ReceiptDate != null || r.Total != null)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var result = new BulkAutoMapResult();
|
var result = new BulkAutoMapResult();
|
||||||
|
|
||||||
foreach (var receipt in unmappedReceipts)
|
foreach (var receipt in unmappedReceipts)
|
||||||
{
|
{
|
||||||
var mapResult = await AutoMapReceiptAsync(receipt.Id);
|
var mapResult = await AutoMapReceiptAsync(receipt.Id);
|
||||||
|
|
||||||
if (mapResult.Status == AutoMapStatus.Success)
|
if (mapResult.Status == AutoMapStatus.Success)
|
||||||
result.MappedCount++;
|
result.MappedCount++;
|
||||||
else if (mapResult.Status == AutoMapStatus.MultipleMatches)
|
else if (mapResult.Status == AutoMapStatus.MultipleMatches)
|
||||||
result.MultipleMatchesCount++;
|
result.MultipleMatchesCount++;
|
||||||
else if (mapResult.Status == AutoMapStatus.NoMatch)
|
else if (mapResult.Status == AutoMapStatus.NoMatch)
|
||||||
result.NoMatchCount++;
|
result.NoMatchCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
result.TotalProcessed = unmappedReceipts.Count;
|
result.TotalProcessed = unmappedReceipts.Count;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<ScoredCandidate>> GetScoredCandidatesAsync(long receiptId)
|
public async Task<List<ScoredCandidate>> GetScoredCandidatesAsync(long receiptId)
|
||||||
{
|
{
|
||||||
var receipt = await _db.Receipts
|
var receipt = await _db.Receipts
|
||||||
.FirstOrDefaultAsync(r => r.Id == receiptId);
|
.FirstOrDefaultAsync(r => r.Id == receiptId);
|
||||||
|
|
||||||
if (receipt == null)
|
if (receipt == null)
|
||||||
return new List<ScoredCandidate>();
|
return new List<ScoredCandidate>();
|
||||||
|
|
||||||
return await FindAndScoreCandidatesAsync(receipt);
|
return await FindAndScoreCandidatesAsync(receipt);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<ScoredCandidate>> FindAndScoreCandidatesAsync(Receipt receipt)
|
private async Task<List<ScoredCandidate>> FindAndScoreCandidatesAsync(Receipt receipt)
|
||||||
{
|
{
|
||||||
// Get transactions in a reasonable date range
|
// Get transactions in a reasonable date range
|
||||||
var query = _db.Transactions
|
var query = _db.Transactions
|
||||||
.Include(t => t.Card)
|
.Include(t => t.Card)
|
||||||
.Include(t => t.Account)
|
.Include(t => t.Account)
|
||||||
.Include(t => t.Merchant)
|
.Include(t => t.Merchant)
|
||||||
.AsQueryable();
|
.AsQueryable();
|
||||||
|
|
||||||
// Date range: use receipt date or due date
|
// Date range: use receipt date or due date
|
||||||
// Transactions can't occur before the receipt date (you get a receipt when you buy something)
|
// Transactions can't occur before the receipt date (you get a receipt when you buy something)
|
||||||
DateTime? targetDate = receipt.ReceiptDate;
|
DateTime? targetDate = receipt.ReceiptDate;
|
||||||
DateTime? dueDate = receipt.DueDate;
|
DateTime? dueDate = receipt.DueDate;
|
||||||
|
|
||||||
if (targetDate.HasValue || dueDate.HasValue)
|
if (targetDate.HasValue || dueDate.HasValue)
|
||||||
{
|
{
|
||||||
// Min date is the receipt date - transactions can't precede the receipt
|
// Min date is the receipt date - transactions can't precede the receipt
|
||||||
var minDate = targetDate ?? dueDate!.Value;
|
var minDate = targetDate ?? dueDate!.Value;
|
||||||
var maxDate = (dueDate ?? targetDate!.Value).AddDays(7);
|
var maxDate = (dueDate ?? targetDate!.Value).AddDays(7);
|
||||||
query = query.Where(t => t.Date >= minDate && t.Date <= maxDate);
|
query = query.Where(t => t.Date >= minDate && t.Date <= maxDate);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// No date info - can't match reliably
|
// No date info - can't match reliably
|
||||||
return new List<ScoredCandidate>();
|
return new List<ScoredCandidate>();
|
||||||
}
|
}
|
||||||
|
|
||||||
var candidates = await query.ToListAsync();
|
var candidates = await query.ToListAsync();
|
||||||
|
|
||||||
// Exclude transactions that already have receipts
|
// Exclude transactions that already have receipts
|
||||||
var transactionsWithReceipts = await _db.Receipts
|
var transactionsWithReceipts = await _db.Receipts
|
||||||
.Where(r => r.TransactionId != null && r.Id != receipt.Id)
|
.Where(r => r.TransactionId != null && r.Id != receipt.Id)
|
||||||
.Select(r => r.TransactionId!.Value)
|
.Select(r => r.TransactionId!.Value)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
candidates = candidates
|
candidates = candidates
|
||||||
.Where(t => !transactionsWithReceipts.Contains(t.Id))
|
.Where(t => !transactionsWithReceipts.Contains(t.Id))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// Score each candidate
|
// Score each candidate
|
||||||
var scored = candidates
|
var scored = candidates
|
||||||
.Select(t => new ScoredCandidate
|
.Select(t => new ScoredCandidate
|
||||||
{
|
{
|
||||||
Transaction = t,
|
Transaction = t,
|
||||||
Score = CalculateMatchScore(receipt, t)
|
Score = CalculateMatchScore(receipt, t)
|
||||||
})
|
})
|
||||||
.Where(s => s.Score > 0.1) // Filter out very poor matches
|
.Where(s => s.Score > 0.1) // Filter out very poor matches
|
||||||
.OrderByDescending(s => s.Score)
|
.OrderByDescending(s => s.Score)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return scored;
|
return scored;
|
||||||
}
|
}
|
||||||
|
|
||||||
private double CalculateMatchScore(Receipt receipt, Transaction transaction)
|
private double CalculateMatchScore(Receipt receipt, Transaction transaction)
|
||||||
{
|
{
|
||||||
double score = 0;
|
double score = 0;
|
||||||
double totalWeight = 0;
|
double totalWeight = 0;
|
||||||
|
|
||||||
// Amount matching (weight: 40%)
|
// Amount matching (weight: 40%)
|
||||||
if (receipt.Total.HasValue)
|
if (receipt.Total.HasValue)
|
||||||
{
|
{
|
||||||
const double amountWeight = 0.40;
|
const double amountWeight = 0.40;
|
||||||
totalWeight += amountWeight;
|
totalWeight += amountWeight;
|
||||||
|
|
||||||
var receiptAmount = Math.Abs(receipt.Total.Value);
|
var receiptAmount = Math.Abs(receipt.Total.Value);
|
||||||
var transactionAmount = Math.Abs(transaction.Amount);
|
var transactionAmount = Math.Abs(transaction.Amount);
|
||||||
|
|
||||||
if (receiptAmount > 0)
|
if (receiptAmount > 0)
|
||||||
{
|
{
|
||||||
var difference = (double)(Math.Abs(receiptAmount - transactionAmount) / receiptAmount);
|
var difference = (double)(Math.Abs(receiptAmount - transactionAmount) / receiptAmount);
|
||||||
|
|
||||||
if (difference == 0)
|
if (difference == 0)
|
||||||
score += amountWeight * 1.0;
|
score += amountWeight * 1.0;
|
||||||
else if (difference <= 0.01) // Within 1%
|
else if (difference <= 0.01) // Within 1%
|
||||||
score += amountWeight * 0.95;
|
score += amountWeight * 0.95;
|
||||||
else if (difference <= 0.05) // Within 5%
|
else if (difference <= 0.05) // Within 5%
|
||||||
score += amountWeight * 0.80;
|
score += amountWeight * 0.80;
|
||||||
else if (difference <= 0.10) // Within 10%
|
else if (difference <= 0.10) // Within 10%
|
||||||
score += amountWeight * 0.60;
|
score += amountWeight * 0.60;
|
||||||
else if (difference <= 0.20) // Within 20%
|
else if (difference <= 0.20) // Within 20%
|
||||||
score += amountWeight * 0.30;
|
score += amountWeight * 0.30;
|
||||||
// Beyond 20% = 0 points
|
// Beyond 20% = 0 points
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Date matching (weight: 25%)
|
// Date matching (weight: 25%)
|
||||||
if (receipt.ReceiptDate.HasValue)
|
if (receipt.ReceiptDate.HasValue)
|
||||||
{
|
{
|
||||||
const double dateWeight = 0.25;
|
const double dateWeight = 0.25;
|
||||||
totalWeight += dateWeight;
|
totalWeight += dateWeight;
|
||||||
|
|
||||||
var daysDiff = Math.Abs((transaction.Date - receipt.ReceiptDate.Value).TotalDays);
|
var daysDiff = Math.Abs((transaction.Date - receipt.ReceiptDate.Value).TotalDays);
|
||||||
|
|
||||||
if (daysDiff == 0)
|
if (daysDiff == 0)
|
||||||
score += dateWeight * 1.0;
|
score += dateWeight * 1.0;
|
||||||
else if (daysDiff <= 1)
|
else if (daysDiff <= 1)
|
||||||
score += dateWeight * 0.90;
|
score += dateWeight * 0.90;
|
||||||
else if (daysDiff <= 3)
|
else if (daysDiff <= 3)
|
||||||
score += dateWeight * 0.70;
|
score += dateWeight * 0.70;
|
||||||
else if (daysDiff <= 5)
|
else if (daysDiff <= 5)
|
||||||
score += dateWeight * 0.50;
|
score += dateWeight * 0.50;
|
||||||
else if (daysDiff <= 7)
|
else if (daysDiff <= 7)
|
||||||
score += dateWeight * 0.30;
|
score += dateWeight * 0.30;
|
||||||
// Beyond 7 days = 0 points
|
// Beyond 7 days = 0 points
|
||||||
}
|
}
|
||||||
|
|
||||||
// Due date matching for bills (weight: 10% bonus)
|
// Due date matching for bills (weight: 10% bonus)
|
||||||
if (receipt.DueDate.HasValue)
|
if (receipt.DueDate.HasValue)
|
||||||
{
|
{
|
||||||
const double dueDateWeight = 0.10;
|
const double dueDateWeight = 0.10;
|
||||||
totalWeight += dueDateWeight;
|
totalWeight += dueDateWeight;
|
||||||
|
|
||||||
var daysDiff = Math.Abs((transaction.Date - receipt.DueDate.Value).TotalDays);
|
var daysDiff = Math.Abs((transaction.Date - receipt.DueDate.Value).TotalDays);
|
||||||
|
|
||||||
if (daysDiff <= 1)
|
if (daysDiff <= 1)
|
||||||
score += dueDateWeight * 1.0;
|
score += dueDateWeight * 1.0;
|
||||||
else if (daysDiff <= 3)
|
else if (daysDiff <= 3)
|
||||||
score += dueDateWeight * 0.70;
|
score += dueDateWeight * 0.70;
|
||||||
else if (daysDiff <= 5)
|
else if (daysDiff <= 5)
|
||||||
score += dueDateWeight * 0.40;
|
score += dueDateWeight * 0.40;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merchant/Name matching (weight: 35%)
|
// Merchant/Name matching (weight: 35%)
|
||||||
if (!string.IsNullOrWhiteSpace(receipt.Merchant))
|
if (!string.IsNullOrWhiteSpace(receipt.Merchant))
|
||||||
{
|
{
|
||||||
const double merchantWeight = 0.35;
|
const double merchantWeight = 0.35;
|
||||||
totalWeight += merchantWeight;
|
totalWeight += merchantWeight;
|
||||||
|
|
||||||
var merchantScore = CalculateMerchantMatchScore(
|
var merchantScore = CalculateMerchantMatchScore(
|
||||||
receipt.Merchant,
|
receipt.Merchant,
|
||||||
transaction.Merchant?.Name,
|
transaction.Merchant?.Name,
|
||||||
transaction.Name);
|
transaction.Name);
|
||||||
|
|
||||||
score += merchantWeight * merchantScore;
|
score += merchantWeight * merchantScore;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize score if we didn't have all data points
|
// Normalize score if we didn't have all data points
|
||||||
if (totalWeight > 0 && totalWeight < 1.0)
|
if (totalWeight > 0 && totalWeight < 1.0)
|
||||||
{
|
{
|
||||||
score = score / totalWeight;
|
score = score / totalWeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.Min(score, 1.0);
|
return Math.Min(score, 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private double CalculateMerchantMatchScore(string receiptMerchant, string? transactionMerchant, string? transactionName)
|
private double CalculateMerchantMatchScore(string receiptMerchant, string? transactionMerchant, string? transactionName)
|
||||||
{
|
{
|
||||||
var receiptLower = receiptMerchant.ToLowerInvariant().Trim();
|
var receiptLower = receiptMerchant.ToLowerInvariant().Trim();
|
||||||
var merchantLower = transactionMerchant?.ToLowerInvariant().Trim() ?? "";
|
var merchantLower = transactionMerchant?.ToLowerInvariant().Trim() ?? "";
|
||||||
var nameLower = transactionName?.ToLowerInvariant().Trim() ?? "";
|
var nameLower = transactionName?.ToLowerInvariant().Trim() ?? "";
|
||||||
|
|
||||||
// Exact match
|
// Exact match
|
||||||
if (receiptLower == merchantLower || receiptLower == nameLower)
|
if (receiptLower == merchantLower || receiptLower == nameLower)
|
||||||
return 1.0;
|
return 1.0;
|
||||||
|
|
||||||
// Contains match
|
// Contains match
|
||||||
if (merchantLower.Contains(receiptLower) || receiptLower.Contains(merchantLower))
|
if (merchantLower.Contains(receiptLower) || receiptLower.Contains(merchantLower))
|
||||||
return 0.90;
|
return 0.90;
|
||||||
if (nameLower.Contains(receiptLower) || receiptLower.Contains(nameLower))
|
if (nameLower.Contains(receiptLower) || receiptLower.Contains(nameLower))
|
||||||
return 0.85;
|
return 0.85;
|
||||||
|
|
||||||
// Word-based matching
|
// Word-based matching
|
||||||
var receiptWords = ExtractWords(receiptLower);
|
var receiptWords = ExtractWords(receiptLower);
|
||||||
var merchantWords = ExtractWords(merchantLower);
|
var merchantWords = ExtractWords(merchantLower);
|
||||||
var nameWords = ExtractWords(nameLower);
|
var nameWords = ExtractWords(nameLower);
|
||||||
|
|
||||||
var merchantMatchRatio = CalculateWordMatchRatio(receiptWords, merchantWords);
|
var merchantMatchRatio = CalculateWordMatchRatio(receiptWords, merchantWords);
|
||||||
var nameMatchRatio = CalculateWordMatchRatio(receiptWords, nameWords);
|
var nameMatchRatio = CalculateWordMatchRatio(receiptWords, nameWords);
|
||||||
|
|
||||||
return Math.Max(merchantMatchRatio, nameMatchRatio);
|
return Math.Max(merchantMatchRatio, nameMatchRatio);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static HashSet<string> ExtractWords(string text)
|
private static HashSet<string> ExtractWords(string text)
|
||||||
{
|
{
|
||||||
return text
|
return text
|
||||||
.Split(new[] { ' ', '-', '_', '.', ',', '#', '/', '\\', '*' }, StringSplitOptions.RemoveEmptyEntries)
|
.Split(new[] { ' ', '-', '_', '.', ',', '#', '/', '\\', '*' }, StringSplitOptions.RemoveEmptyEntries)
|
||||||
.Where(w => w.Length > 1) // Skip single chars
|
.Where(w => w.Length > 1) // Skip single chars
|
||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static double CalculateWordMatchRatio(HashSet<string> words1, HashSet<string> words2)
|
private static double CalculateWordMatchRatio(HashSet<string> words1, HashSet<string> words2)
|
||||||
{
|
{
|
||||||
if (words1.Count == 0 || words2.Count == 0)
|
if (words1.Count == 0 || words2.Count == 0)
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
int matches = 0;
|
int matches = 0;
|
||||||
foreach (var w1 in words1)
|
foreach (var w1 in words1)
|
||||||
{
|
{
|
||||||
foreach (var w2 in words2)
|
foreach (var w2 in words2)
|
||||||
{
|
{
|
||||||
if (w1 == w2 || w1.Contains(w2) || w2.Contains(w1))
|
if (w1 == w2 || w1.Contains(w2) || w2.Contains(w1))
|
||||||
{
|
{
|
||||||
matches++;
|
matches++;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return ratio of matched words from the smaller set
|
// Return ratio of matched words from the smaller set
|
||||||
var smallerCount = Math.Min(words1.Count, words2.Count);
|
var smallerCount = Math.Min(words1.Count, words2.Count);
|
||||||
return (double)matches / smallerCount;
|
return (double)matches / smallerCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<LlmMatchResult?> GetLlmMatchDecisionAsync(Receipt receipt, List<ScoredCandidate> candidates)
|
private async Task<LlmMatchResult?> GetLlmMatchDecisionAsync(Receipt receipt, List<ScoredCandidate> candidates)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var prompt = BuildLlmPrompt(receipt, candidates);
|
var prompt = BuildLlmPrompt(receipt, candidates);
|
||||||
|
|
||||||
_logger.LogInformation("Sending receipt matching prompt to LLM for receipt {ReceiptId}", receipt.Id);
|
_logger.LogInformation("Sending receipt matching prompt to LLM for receipt {ReceiptId}", receipt.Id);
|
||||||
|
|
||||||
var result = await _llmClient.SendTextPromptAsync(prompt);
|
var result = await _llmClient.SendTextPromptAsync(prompt);
|
||||||
|
|
||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("LLM matching failed: {Error}", result.ErrorMessage);
|
_logger.LogWarning("LLM matching failed: {Error}", result.ErrorMessage);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("LLM response: {Content}", result.Content);
|
_logger.LogInformation("LLM response: {Content}", result.Content);
|
||||||
|
|
||||||
return ParseLlmResponse(result.Content, candidates);
|
return ParseLlmResponse(result.Content, candidates);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error during LLM match decision");
|
_logger.LogError(ex, "Error during LLM match decision");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string BuildLlmPrompt(Receipt receipt, List<ScoredCandidate> candidates)
|
private static string BuildLlmPrompt(Receipt receipt, List<ScoredCandidate> candidates)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
sb.AppendLine("You are matching a receipt to bank transactions. Analyze and pick the best match.");
|
sb.AppendLine("You are matching a receipt to bank transactions. Analyze and pick the best match.");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
sb.AppendLine("RECEIPT:");
|
sb.AppendLine("RECEIPT:");
|
||||||
sb.AppendLine($" Merchant: {receipt.Merchant ?? "Unknown"}");
|
sb.AppendLine($" Merchant: {receipt.Merchant ?? "Unknown"}");
|
||||||
sb.AppendLine($" Date: {receipt.ReceiptDate?.ToString("yyyy-MM-dd") ?? "Unknown"}");
|
sb.AppendLine($" Date: {receipt.ReceiptDate?.ToString("yyyy-MM-dd") ?? "Unknown"}");
|
||||||
if (receipt.DueDate.HasValue)
|
if (receipt.DueDate.HasValue)
|
||||||
sb.AppendLine($" Due Date: {receipt.DueDate.Value:yyyy-MM-dd}");
|
sb.AppendLine($" Due Date: {receipt.DueDate.Value:yyyy-MM-dd}");
|
||||||
sb.AppendLine($" Total: {receipt.Total?.ToString("C") ?? "Unknown"}");
|
sb.AppendLine($" Total: {receipt.Total?.ToString("C") ?? "Unknown"}");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
sb.AppendLine("CANDIDATE TRANSACTIONS:");
|
sb.AppendLine("CANDIDATE TRANSACTIONS:");
|
||||||
|
|
||||||
for (int i = 0; i < candidates.Count; i++)
|
for (int i = 0; i < candidates.Count; i++)
|
||||||
{
|
{
|
||||||
var t = candidates[i].Transaction;
|
var t = candidates[i].Transaction;
|
||||||
sb.AppendLine($" [{i + 1}] ID={t.Id}");
|
sb.AppendLine($" [{i + 1}] ID={t.Id}");
|
||||||
sb.AppendLine($" Name: {t.Name}");
|
sb.AppendLine($" Name: {t.Name}");
|
||||||
if (t.Merchant != null)
|
if (t.Merchant != null)
|
||||||
sb.AppendLine($" Merchant: {t.Merchant.Name}");
|
sb.AppendLine($" Merchant: {t.Merchant.Name}");
|
||||||
sb.AppendLine($" Date: {t.Date:yyyy-MM-dd}");
|
sb.AppendLine($" Date: {t.Date:yyyy-MM-dd}");
|
||||||
sb.AppendLine($" Amount: {t.Amount:C}");
|
sb.AppendLine($" Amount: {t.Amount:C}");
|
||||||
sb.AppendLine($" Current Score: {candidates[i].Score:P0}");
|
sb.AppendLine($" Current Score: {candidates[i].Score:P0}");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.AppendLine("Respond with JSON only:");
|
sb.AppendLine("Respond with JSON only:");
|
||||||
sb.AppendLine("{");
|
sb.AppendLine("{");
|
||||||
sb.AppendLine(" \"match_index\": <1-based index of best match, or 0 if none match>,");
|
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(" \"confidence\": <0.0 to 1.0>,");
|
||||||
sb.AppendLine(" \"reason\": \"<brief explanation>\"");
|
sb.AppendLine(" \"reason\": \"<brief explanation>\"");
|
||||||
sb.AppendLine("}");
|
sb.AppendLine("}");
|
||||||
|
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private LlmMatchResult? ParseLlmResponse(string? content, List<ScoredCandidate> candidates)
|
private LlmMatchResult? ParseLlmResponse(string? content, List<ScoredCandidate> candidates)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(content))
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var json = JsonSerializer.Deserialize<JsonElement>(content);
|
var json = JsonSerializer.Deserialize<JsonElement>(content);
|
||||||
|
|
||||||
var matchIndex = json.GetProperty("match_index").GetInt32();
|
var matchIndex = json.GetProperty("match_index").GetInt32();
|
||||||
var confidence = json.GetProperty("confidence").GetDouble();
|
var confidence = json.GetProperty("confidence").GetDouble();
|
||||||
|
|
||||||
if (matchIndex <= 0 || matchIndex > candidates.Count)
|
if (matchIndex <= 0 || matchIndex > candidates.Count)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
return new LlmMatchResult
|
return new LlmMatchResult
|
||||||
{
|
{
|
||||||
TransactionId = candidates[matchIndex - 1].Transaction.Id,
|
TransactionId = candidates[matchIndex - 1].Transaction.Id,
|
||||||
Confidence = confidence
|
Confidence = confidence
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to parse LLM response: {Content}", content);
|
_logger.LogWarning(ex, "Failed to parse LLM response: {Content}", content);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ScoredCandidate
|
public class ScoredCandidate
|
||||||
{
|
{
|
||||||
public required Transaction Transaction { get; set; }
|
public required Transaction Transaction { get; set; }
|
||||||
public double Score { get; set; }
|
public double Score { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class LlmMatchResult
|
public class LlmMatchResult
|
||||||
{
|
{
|
||||||
public long TransactionId { get; set; }
|
public long TransactionId { get; set; }
|
||||||
public double Confidence { get; set; }
|
public double Confidence { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ReceiptAutoMapResult
|
public class ReceiptAutoMapResult
|
||||||
{
|
{
|
||||||
public AutoMapStatus Status { get; init; }
|
public AutoMapStatus Status { get; init; }
|
||||||
public long? TransactionId { get; init; }
|
public long? TransactionId { get; init; }
|
||||||
public List<Transaction> MultipleMatches { get; init; } = new();
|
public List<Transaction> MultipleMatches { get; init; } = new();
|
||||||
public string? Message { get; init; }
|
public string? Message { get; init; }
|
||||||
|
|
||||||
public static ReceiptAutoMapResult Success(long transactionId) =>
|
public static ReceiptAutoMapResult Success(long transactionId) =>
|
||||||
new() { Status = AutoMapStatus.Success, TransactionId = transactionId };
|
new() { Status = AutoMapStatus.Success, TransactionId = transactionId };
|
||||||
|
|
||||||
public static ReceiptAutoMapResult AlreadyMapped(long transactionId) =>
|
public static ReceiptAutoMapResult AlreadyMapped(long transactionId) =>
|
||||||
new() { Status = AutoMapStatus.AlreadyMapped, TransactionId = transactionId };
|
new() { Status = AutoMapStatus.AlreadyMapped, TransactionId = transactionId };
|
||||||
|
|
||||||
public static ReceiptAutoMapResult NoMatch() =>
|
public static ReceiptAutoMapResult NoMatch() =>
|
||||||
new() { Status = AutoMapStatus.NoMatch, Message = "No matching transaction found." };
|
new() { Status = AutoMapStatus.NoMatch, Message = "No matching transaction found." };
|
||||||
|
|
||||||
public static ReceiptAutoMapResult WithMultipleMatches(List<Transaction> matches) =>
|
public static ReceiptAutoMapResult WithMultipleMatches(List<Transaction> matches) =>
|
||||||
new() { Status = AutoMapStatus.MultipleMatches, MultipleMatches = matches, Message = $"Found {matches.Count} potential matches." };
|
new() { Status = AutoMapStatus.MultipleMatches, MultipleMatches = matches, Message = $"Found {matches.Count} potential matches." };
|
||||||
|
|
||||||
public static ReceiptAutoMapResult NotParsed() =>
|
public static ReceiptAutoMapResult NotParsed() =>
|
||||||
new() { Status = AutoMapStatus.NotParsed, Message = "Receipt has not been parsed yet." };
|
new() { Status = AutoMapStatus.NotParsed, Message = "Receipt has not been parsed yet." };
|
||||||
|
|
||||||
public static ReceiptAutoMapResult Failure(string message) =>
|
public static ReceiptAutoMapResult Failure(string message) =>
|
||||||
new() { Status = AutoMapStatus.Failed, Message = message };
|
new() { Status = AutoMapStatus.Failed, Message = message };
|
||||||
}
|
}
|
||||||
|
|
||||||
public class BulkAutoMapResult
|
public class BulkAutoMapResult
|
||||||
{
|
{
|
||||||
public int TotalProcessed { get; set; }
|
public int TotalProcessed { get; set; }
|
||||||
public int MappedCount { get; set; }
|
public int MappedCount { get; set; }
|
||||||
public int NoMatchCount { get; set; }
|
public int NoMatchCount { get; set; }
|
||||||
public int MultipleMatchesCount { get; set; }
|
public int MultipleMatchesCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum AutoMapStatus
|
public enum AutoMapStatus
|
||||||
{
|
{
|
||||||
Success,
|
Success,
|
||||||
AlreadyMapped,
|
AlreadyMapped,
|
||||||
NoMatch,
|
NoMatch,
|
||||||
MultipleMatches,
|
MultipleMatches,
|
||||||
NotParsed,
|
NotParsed,
|
||||||
Failed
|
Failed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,405 +1,405 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using MoneyMap.Data;
|
using MoneyMap.Data;
|
||||||
using MoneyMap.Models;
|
using MoneyMap.Models;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace MoneyMap.Services
|
namespace MoneyMap.Services
|
||||||
{
|
{
|
||||||
public interface IReceiptManager
|
public interface IReceiptManager
|
||||||
{
|
{
|
||||||
Task<ReceiptUploadResult> UploadReceiptAsync(long transactionId, IFormFile file);
|
Task<ReceiptUploadResult> UploadReceiptAsync(long transactionId, IFormFile file);
|
||||||
Task<ReceiptUploadResult> UploadUnmappedReceiptAsync(IFormFile file);
|
Task<ReceiptUploadResult> UploadUnmappedReceiptAsync(IFormFile file);
|
||||||
Task<BulkUploadResult> UploadManyUnmappedReceiptsAsync(IReadOnlyList<IFormFile> files);
|
Task<BulkUploadResult> UploadManyUnmappedReceiptsAsync(IReadOnlyList<IFormFile> files);
|
||||||
Task<bool> DeleteReceiptAsync(long receiptId);
|
Task<bool> DeleteReceiptAsync(long receiptId);
|
||||||
Task<bool> MapReceiptToTransactionAsync(long receiptId, long transactionId);
|
Task<bool> MapReceiptToTransactionAsync(long receiptId, long transactionId);
|
||||||
Task<bool> UnmapReceiptAsync(long receiptId);
|
Task<bool> UnmapReceiptAsync(long receiptId);
|
||||||
string GetReceiptPhysicalPath(Receipt receipt);
|
string GetReceiptPhysicalPath(Receipt receipt);
|
||||||
Task<Receipt?> GetReceiptAsync(long receiptId);
|
Task<Receipt?> GetReceiptAsync(long receiptId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ReceiptManager : IReceiptManager
|
public class ReceiptManager : IReceiptManager
|
||||||
{
|
{
|
||||||
private readonly MoneyMapContext _db;
|
private readonly MoneyMapContext _db;
|
||||||
private readonly IReceiptStorageOptions _receiptStorage;
|
private readonly IReceiptStorageOptions _receiptStorage;
|
||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private readonly IReceiptParseQueue _parseQueue;
|
private readonly IReceiptParseQueue _parseQueue;
|
||||||
private readonly ILogger<ReceiptManager> _logger;
|
private readonly ILogger<ReceiptManager> _logger;
|
||||||
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
|
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
|
||||||
private static readonly string[] AllowedExtensions = { ".jpg", ".jpeg", ".png", ".pdf", ".gif", ".heic" };
|
private static readonly string[] AllowedExtensions = { ".jpg", ".jpeg", ".png", ".pdf", ".gif", ".heic" };
|
||||||
|
|
||||||
// Magic bytes for file type validation (prevents extension spoofing)
|
// Magic bytes for file type validation (prevents extension spoofing)
|
||||||
private static readonly Dictionary<string, byte[][]> FileSignatures = new()
|
private static readonly Dictionary<string, byte[][]> FileSignatures = new()
|
||||||
{
|
{
|
||||||
{ ".jpg", new[] { new byte[] { 0xFF, 0xD8, 0xFF } } },
|
{ ".jpg", new[] { new byte[] { 0xFF, 0xD8, 0xFF } } },
|
||||||
{ ".jpeg", 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 } } },
|
{ ".png", new[] { new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A } } },
|
||||||
{ ".gif", new[] { new byte[] { 0x47, 0x49, 0x46, 0x38 } } }, // GIF87a or GIF89a
|
{ ".gif", new[] { new byte[] { 0x47, 0x49, 0x46, 0x38 } } }, // GIF87a or GIF89a
|
||||||
{ ".pdf", new[] { new byte[] { 0x25, 0x50, 0x44, 0x46 } } }, // %PDF
|
{ ".pdf", new[] { new byte[] { 0x25, 0x50, 0x44, 0x46 } } }, // %PDF
|
||||||
{ ".heic", new[] {
|
{ ".heic", new[] {
|
||||||
new byte[] { 0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63 }, // ftypheic
|
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, 0x1C, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63 }, // ftypheic (variant)
|
||||||
new byte[] { 0x00, 0x00, 0x00 } // Generic ftyp header (relaxed check)
|
new byte[] { 0x00, 0x00, 0x00 } // Generic ftyp header (relaxed check)
|
||||||
}}
|
}}
|
||||||
};
|
};
|
||||||
|
|
||||||
public ReceiptManager(
|
public ReceiptManager(
|
||||||
MoneyMapContext db,
|
MoneyMapContext db,
|
||||||
IReceiptStorageOptions receiptStorage,
|
IReceiptStorageOptions receiptStorage,
|
||||||
IServiceProvider serviceProvider,
|
IServiceProvider serviceProvider,
|
||||||
IReceiptParseQueue parseQueue,
|
IReceiptParseQueue parseQueue,
|
||||||
ILogger<ReceiptManager> logger)
|
ILogger<ReceiptManager> logger)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_receiptStorage = receiptStorage;
|
_receiptStorage = receiptStorage;
|
||||||
_serviceProvider = serviceProvider;
|
_serviceProvider = serviceProvider;
|
||||||
_parseQueue = parseQueue;
|
_parseQueue = parseQueue;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetReceiptsBasePath()
|
private string GetReceiptsBasePath()
|
||||||
{
|
{
|
||||||
return _receiptStorage.ReceiptsBasePath;
|
return _receiptStorage.ReceiptsBasePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ReceiptUploadResult> UploadReceiptAsync(long transactionId, IFormFile file)
|
public async Task<ReceiptUploadResult> UploadReceiptAsync(long transactionId, IFormFile file)
|
||||||
{
|
{
|
||||||
// Verify transaction exists
|
// Verify transaction exists
|
||||||
var transaction = await _db.Transactions.FindAsync(transactionId);
|
var transaction = await _db.Transactions.FindAsync(transactionId);
|
||||||
if (transaction == null)
|
if (transaction == null)
|
||||||
return ReceiptUploadResult.Failure("Transaction not found.");
|
return ReceiptUploadResult.Failure("Transaction not found.");
|
||||||
|
|
||||||
return await UploadReceiptInternalAsync(file, transactionId);
|
return await UploadReceiptInternalAsync(file, transactionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ReceiptUploadResult> UploadUnmappedReceiptAsync(IFormFile file)
|
public async Task<ReceiptUploadResult> UploadUnmappedReceiptAsync(IFormFile file)
|
||||||
{
|
{
|
||||||
return await UploadReceiptInternalAsync(file, null);
|
return await UploadReceiptInternalAsync(file, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ReceiptUploadResult> UploadReceiptInternalAsync(IFormFile file, long? transactionId)
|
private async Task<ReceiptUploadResult> UploadReceiptInternalAsync(IFormFile file, long? transactionId)
|
||||||
{
|
{
|
||||||
// Validate file
|
// Validate file
|
||||||
if (file == null || file.Length == 0)
|
if (file == null || file.Length == 0)
|
||||||
return ReceiptUploadResult.Failure("No file selected.");
|
return ReceiptUploadResult.Failure("No file selected.");
|
||||||
|
|
||||||
if (file.Length > MaxFileSize)
|
if (file.Length > MaxFileSize)
|
||||||
return ReceiptUploadResult.Failure($"File size exceeds {MaxFileSize / 1024 / 1024}MB limit.");
|
return ReceiptUploadResult.Failure($"File size exceeds {MaxFileSize / 1024 / 1024}MB limit.");
|
||||||
|
|
||||||
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||||
if (!AllowedExtensions.Contains(extension))
|
if (!AllowedExtensions.Contains(extension))
|
||||||
return ReceiptUploadResult.Failure($"File type {extension} not allowed. Use: {string.Join(", ", AllowedExtensions)}");
|
return ReceiptUploadResult.Failure($"File type {extension} not allowed. Use: {string.Join(", ", AllowedExtensions)}");
|
||||||
|
|
||||||
// Validate file content matches extension (magic bytes check)
|
// Validate file content matches extension (magic bytes check)
|
||||||
if (!await ValidateFileSignatureAsync(file, extension))
|
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.");
|
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
|
// Create receipts directory if it doesn't exist
|
||||||
var receiptsBasePath = GetReceiptsBasePath();
|
var receiptsBasePath = GetReceiptsBasePath();
|
||||||
if (!Directory.Exists(receiptsBasePath))
|
if (!Directory.Exists(receiptsBasePath))
|
||||||
Directory.CreateDirectory(receiptsBasePath);
|
Directory.CreateDirectory(receiptsBasePath);
|
||||||
|
|
||||||
// Calculate SHA256 hash
|
// Calculate SHA256 hash
|
||||||
string fileHash;
|
string fileHash;
|
||||||
using (var sha256 = SHA256.Create())
|
using (var sha256 = SHA256.Create())
|
||||||
{
|
{
|
||||||
using var stream = file.OpenReadStream();
|
using var stream = file.OpenReadStream();
|
||||||
var hashBytes = await sha256.ComputeHashAsync(stream);
|
var hashBytes = await sha256.ComputeHashAsync(stream);
|
||||||
fileHash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
|
fileHash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for exact duplicate (same transaction + same hash)
|
// Check for exact duplicate (same transaction + same hash)
|
||||||
if (transactionId.HasValue)
|
if (transactionId.HasValue)
|
||||||
{
|
{
|
||||||
var existingReceipt = await _db.Receipts
|
var existingReceipt = await _db.Receipts
|
||||||
.FirstOrDefaultAsync(r => r.TransactionId == transactionId && r.FileHashSha256 == fileHash);
|
.FirstOrDefaultAsync(r => r.TransactionId == transactionId && r.FileHashSha256 == fileHash);
|
||||||
|
|
||||||
if (existingReceipt != null)
|
if (existingReceipt != null)
|
||||||
return ReceiptUploadResult.Failure("This receipt has already been uploaded for this transaction.");
|
return ReceiptUploadResult.Failure("This receipt has already been uploaded for this transaction.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for potential duplicates (same hash, same name+size)
|
// Check for potential duplicates (same hash, same name+size)
|
||||||
var duplicateWarnings = await CheckForDuplicatesAsync(fileHash, file.FileName, file.Length);
|
var duplicateWarnings = await CheckForDuplicatesAsync(fileHash, file.FileName, file.Length);
|
||||||
|
|
||||||
// Generate unique filename
|
// Generate unique filename
|
||||||
var storedFileName = $"{transactionId?.ToString() ?? "unmapped"}_{Guid.NewGuid()}{extension}";
|
var storedFileName = $"{transactionId?.ToString() ?? "unmapped"}_{Guid.NewGuid()}{extension}";
|
||||||
var filePath = Path.Combine(receiptsBasePath, storedFileName);
|
var filePath = Path.Combine(receiptsBasePath, storedFileName);
|
||||||
|
|
||||||
// Save file
|
// Save file
|
||||||
using (var fileStream = new FileStream(filePath, FileMode.Create))
|
using (var fileStream = new FileStream(filePath, FileMode.Create))
|
||||||
{
|
{
|
||||||
await file.CopyToAsync(fileStream);
|
await file.CopyToAsync(fileStream);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store just the filename in database (base path comes from config)
|
// Store just the filename in database (base path comes from config)
|
||||||
var relativeStoragePath = storedFileName;
|
var relativeStoragePath = storedFileName;
|
||||||
|
|
||||||
// Create receipt record
|
// Create receipt record
|
||||||
var receipt = new Receipt
|
var receipt = new Receipt
|
||||||
{
|
{
|
||||||
TransactionId = transactionId,
|
TransactionId = transactionId,
|
||||||
FileName = SanitizeFileName(file.FileName),
|
FileName = SanitizeFileName(file.FileName),
|
||||||
StoragePath = relativeStoragePath,
|
StoragePath = relativeStoragePath,
|
||||||
FileSizeBytes = file.Length,
|
FileSizeBytes = file.Length,
|
||||||
ContentType = file.ContentType,
|
ContentType = file.ContentType,
|
||||||
FileHashSha256 = fileHash,
|
FileHashSha256 = fileHash,
|
||||||
UploadedAtUtc = DateTime.UtcNow
|
UploadedAtUtc = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
|
|
||||||
receipt.ParseStatus = ReceiptParseStatus.Queued;
|
receipt.ParseStatus = ReceiptParseStatus.Queued;
|
||||||
_db.Receipts.Add(receipt);
|
_db.Receipts.Add(receipt);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
await _parseQueue.EnqueueAsync(receipt.Id);
|
await _parseQueue.EnqueueAsync(receipt.Id);
|
||||||
_logger.LogInformation("Receipt {ReceiptId} enqueued for parsing", receipt.Id);
|
_logger.LogInformation("Receipt {ReceiptId} enqueued for parsing", receipt.Id);
|
||||||
|
|
||||||
return ReceiptUploadResult.Success(receipt, duplicateWarnings);
|
return ReceiptUploadResult.Success(receipt, duplicateWarnings);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<BulkUploadResult> UploadManyUnmappedReceiptsAsync(IReadOnlyList<IFormFile> files)
|
public async Task<BulkUploadResult> UploadManyUnmappedReceiptsAsync(IReadOnlyList<IFormFile> files)
|
||||||
{
|
{
|
||||||
var uploaded = new List<BulkUploadItem>();
|
var uploaded = new List<BulkUploadItem>();
|
||||||
var failed = new List<BulkUploadFailure>();
|
var failed = new List<BulkUploadFailure>();
|
||||||
|
|
||||||
foreach (var file in files)
|
foreach (var file in files)
|
||||||
{
|
{
|
||||||
var result = await UploadReceiptInternalAsync(file, null);
|
var result = await UploadReceiptInternalAsync(file, null);
|
||||||
if (result.IsSuccess)
|
if (result.IsSuccess)
|
||||||
{
|
{
|
||||||
uploaded.Add(new BulkUploadItem
|
uploaded.Add(new BulkUploadItem
|
||||||
{
|
{
|
||||||
ReceiptId = result.Receipt!.Id,
|
ReceiptId = result.Receipt!.Id,
|
||||||
FileName = result.Receipt.FileName,
|
FileName = result.Receipt.FileName,
|
||||||
DuplicateWarnings = result.DuplicateWarnings
|
DuplicateWarnings = result.DuplicateWarnings
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
failed.Add(new BulkUploadFailure
|
failed.Add(new BulkUploadFailure
|
||||||
{
|
{
|
||||||
FileName = file.FileName,
|
FileName = file.FileName,
|
||||||
ErrorMessage = result.ErrorMessage ?? "Unknown error"
|
ErrorMessage = result.ErrorMessage ?? "Unknown error"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new BulkUploadResult
|
return new BulkUploadResult
|
||||||
{
|
{
|
||||||
Uploaded = uploaded,
|
Uploaded = uploaded,
|
||||||
Failed = failed
|
Failed = failed
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<DuplicateWarning>> CheckForDuplicatesAsync(string fileHash, string fileName, long fileSize)
|
private async Task<List<DuplicateWarning>> CheckForDuplicatesAsync(string fileHash, string fileName, long fileSize)
|
||||||
{
|
{
|
||||||
var warnings = new List<DuplicateWarning>();
|
var warnings = new List<DuplicateWarning>();
|
||||||
|
|
||||||
// Check for receipts with same hash
|
// Check for receipts with same hash
|
||||||
var hashMatches = await _db.Receipts
|
var hashMatches = await _db.Receipts
|
||||||
.Include(r => r.Transaction)
|
.Include(r => r.Transaction)
|
||||||
.Where(r => r.FileHashSha256 == fileHash)
|
.Where(r => r.FileHashSha256 == fileHash)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
foreach (var match in hashMatches)
|
foreach (var match in hashMatches)
|
||||||
{
|
{
|
||||||
warnings.Add(new DuplicateWarning
|
warnings.Add(new DuplicateWarning
|
||||||
{
|
{
|
||||||
ReceiptId = match.Id,
|
ReceiptId = match.Id,
|
||||||
FileName = match.FileName,
|
FileName = match.FileName,
|
||||||
UploadedAtUtc = match.UploadedAtUtc,
|
UploadedAtUtc = match.UploadedAtUtc,
|
||||||
TransactionId = match.TransactionId,
|
TransactionId = match.TransactionId,
|
||||||
TransactionName = match.Transaction?.Name,
|
TransactionName = match.Transaction?.Name,
|
||||||
Reason = "Identical file content (same hash)"
|
Reason = "Identical file content (same hash)"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for receipts with same name and size (but different hash - might be resaved/edited)
|
// Check for receipts with same name and size (but different hash - might be resaved/edited)
|
||||||
if (!warnings.Any())
|
if (!warnings.Any())
|
||||||
{
|
{
|
||||||
var nameAndSizeMatches = await _db.Receipts
|
var nameAndSizeMatches = await _db.Receipts
|
||||||
.Include(r => r.Transaction)
|
.Include(r => r.Transaction)
|
||||||
.Where(r => r.FileName == fileName && r.FileSizeBytes == fileSize)
|
.Where(r => r.FileName == fileName && r.FileSizeBytes == fileSize)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
foreach (var match in nameAndSizeMatches)
|
foreach (var match in nameAndSizeMatches)
|
||||||
{
|
{
|
||||||
warnings.Add(new DuplicateWarning
|
warnings.Add(new DuplicateWarning
|
||||||
{
|
{
|
||||||
ReceiptId = match.Id,
|
ReceiptId = match.Id,
|
||||||
FileName = match.FileName,
|
FileName = match.FileName,
|
||||||
UploadedAtUtc = match.UploadedAtUtc,
|
UploadedAtUtc = match.UploadedAtUtc,
|
||||||
TransactionId = match.TransactionId,
|
TransactionId = match.TransactionId,
|
||||||
TransactionName = match.Transaction?.Name,
|
TransactionName = match.Transaction?.Name,
|
||||||
Reason = "Same file name and size"
|
Reason = "Same file name and size"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return warnings;
|
return warnings;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> MapReceiptToTransactionAsync(long receiptId, long transactionId)
|
public async Task<bool> MapReceiptToTransactionAsync(long receiptId, long transactionId)
|
||||||
{
|
{
|
||||||
var receipt = await _db.Receipts.FindAsync(receiptId);
|
var receipt = await _db.Receipts.FindAsync(receiptId);
|
||||||
if (receipt == null)
|
if (receipt == null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
var transaction = await _db.Transactions.FindAsync(transactionId);
|
var transaction = await _db.Transactions.FindAsync(transactionId);
|
||||||
if (transaction == null)
|
if (transaction == null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Allow remapping: simply update the TransactionId
|
// Allow remapping: simply update the TransactionId
|
||||||
if (receipt.TransactionId == transactionId)
|
if (receipt.TransactionId == transactionId)
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
receipt.TransactionId = transactionId;
|
receipt.TransactionId = transactionId;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> UnmapReceiptAsync(long receiptId)
|
public async Task<bool> UnmapReceiptAsync(long receiptId)
|
||||||
{
|
{
|
||||||
var receipt = await _db.Receipts.FindAsync(receiptId);
|
var receipt = await _db.Receipts.FindAsync(receiptId);
|
||||||
if (receipt == null)
|
if (receipt == null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Set TransactionId to null to unmap
|
// Set TransactionId to null to unmap
|
||||||
receipt.TransactionId = null;
|
receipt.TransactionId = null;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<bool> ValidateFileSignatureAsync(IFormFile file, string extension)
|
private static async Task<bool> ValidateFileSignatureAsync(IFormFile file, string extension)
|
||||||
{
|
{
|
||||||
if (!FileSignatures.TryGetValue(extension, out var signatures))
|
if (!FileSignatures.TryGetValue(extension, out var signatures))
|
||||||
return true; // No signature check for unknown extensions
|
return true; // No signature check for unknown extensions
|
||||||
|
|
||||||
var maxSignatureLength = signatures.Max(s => s.Length);
|
var maxSignatureLength = signatures.Max(s => s.Length);
|
||||||
var headerBytes = new byte[Math.Min(maxSignatureLength, (int)file.Length)];
|
var headerBytes = new byte[Math.Min(maxSignatureLength, (int)file.Length)];
|
||||||
|
|
||||||
await using var stream = file.OpenReadStream();
|
await using var stream = file.OpenReadStream();
|
||||||
_ = await stream.ReadAsync(headerBytes.AsMemory(0, headerBytes.Length));
|
_ = await stream.ReadAsync(headerBytes.AsMemory(0, headerBytes.Length));
|
||||||
|
|
||||||
// Check if file starts with any of the valid signatures for this extension
|
// Check if file starts with any of the valid signatures for this extension
|
||||||
return signatures.Any(signature =>
|
return signatures.Any(signature =>
|
||||||
headerBytes.Length >= signature.Length &&
|
headerBytes.Length >= signature.Length &&
|
||||||
headerBytes.Take(signature.Length).SequenceEqual(signature));
|
headerBytes.Take(signature.Length).SequenceEqual(signature));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string SanitizeFileName(string fileName)
|
private static string SanitizeFileName(string fileName)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(fileName))
|
if (string.IsNullOrWhiteSpace(fileName))
|
||||||
return "receipt";
|
return "receipt";
|
||||||
|
|
||||||
// Remove non-ASCII characters and replace them with safe equivalents
|
// Remove non-ASCII characters and replace them with safe equivalents
|
||||||
var sanitized = new StringBuilder();
|
var sanitized = new StringBuilder();
|
||||||
foreach (var c in fileName)
|
foreach (var c in fileName)
|
||||||
{
|
{
|
||||||
if (c == '�' || c == '�' || c == '�')
|
if (c == '�' || c == '�' || c == '�')
|
||||||
{
|
{
|
||||||
// Skip trademark/copyright symbols
|
// Skip trademark/copyright symbols
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
else if (c >= 32 && c <= 126)
|
else if (c >= 32 && c <= 126)
|
||||||
{
|
{
|
||||||
// Keep ASCII printable characters
|
// Keep ASCII printable characters
|
||||||
sanitized.Append(c);
|
sanitized.Append(c);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Replace other non-ASCII with underscore
|
// Replace other non-ASCII with underscore
|
||||||
sanitized.Append('_');
|
sanitized.Append('_');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = sanitized.ToString().Trim();
|
var result = sanitized.ToString().Trim();
|
||||||
return string.IsNullOrWhiteSpace(result) ? "receipt" : result;
|
return string.IsNullOrWhiteSpace(result) ? "receipt" : result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> DeleteReceiptAsync(long receiptId)
|
public async Task<bool> DeleteReceiptAsync(long receiptId)
|
||||||
{
|
{
|
||||||
var receipt = await _db.Receipts.FindAsync(receiptId);
|
var receipt = await _db.Receipts.FindAsync(receiptId);
|
||||||
if (receipt == null)
|
if (receipt == null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Delete physical file
|
// Delete physical file
|
||||||
var filePath = GetReceiptPhysicalPath(receipt);
|
var filePath = GetReceiptPhysicalPath(receipt);
|
||||||
if (File.Exists(filePath))
|
if (File.Exists(filePath))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
File.Delete(filePath);
|
File.Delete(filePath);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// Continue even if file delete fails
|
// Continue even if file delete fails
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete database record (cascade will handle ParseLogs and LineItems)
|
// Delete database record (cascade will handle ParseLogs and LineItems)
|
||||||
_db.Receipts.Remove(receipt);
|
_db.Receipts.Remove(receipt);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetReceiptPhysicalPath(Receipt receipt)
|
public string GetReceiptPhysicalPath(Receipt receipt)
|
||||||
{
|
{
|
||||||
// StoragePath is just the filename, combine with configured base path
|
// StoragePath is just the filename, combine with configured base path
|
||||||
return Path.Combine(GetReceiptsBasePath(), receipt.StoragePath);
|
return Path.Combine(GetReceiptsBasePath(), receipt.StoragePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Receipt?> GetReceiptAsync(long receiptId)
|
public async Task<Receipt?> GetReceiptAsync(long receiptId)
|
||||||
{
|
{
|
||||||
return await _db.Receipts
|
return await _db.Receipts
|
||||||
.Include(r => r.Transaction)
|
.Include(r => r.Transaction)
|
||||||
.FirstOrDefaultAsync(r => r.Id == receiptId);
|
.FirstOrDefaultAsync(r => r.Id == receiptId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ReceiptUploadResult
|
public class ReceiptUploadResult
|
||||||
{
|
{
|
||||||
public bool IsSuccess { get; init; }
|
public bool IsSuccess { get; init; }
|
||||||
public Receipt? Receipt { get; init; }
|
public Receipt? Receipt { get; init; }
|
||||||
public string? ErrorMessage { get; init; }
|
public string? ErrorMessage { get; init; }
|
||||||
public List<DuplicateWarning> DuplicateWarnings { get; init; } = new();
|
public List<DuplicateWarning> DuplicateWarnings { get; init; } = new();
|
||||||
|
|
||||||
public static ReceiptUploadResult Success(Receipt receipt, List<DuplicateWarning>? warnings = null) =>
|
public static ReceiptUploadResult Success(Receipt receipt, List<DuplicateWarning>? warnings = null) =>
|
||||||
new() { IsSuccess = true, Receipt = receipt, DuplicateWarnings = warnings ?? new() };
|
new() { IsSuccess = true, Receipt = receipt, DuplicateWarnings = warnings ?? new() };
|
||||||
|
|
||||||
public static ReceiptUploadResult Failure(string error) =>
|
public static ReceiptUploadResult Failure(string error) =>
|
||||||
new() { IsSuccess = false, ErrorMessage = error };
|
new() { IsSuccess = false, ErrorMessage = error };
|
||||||
}
|
}
|
||||||
|
|
||||||
public class DuplicateWarning
|
public class DuplicateWarning
|
||||||
{
|
{
|
||||||
public long ReceiptId { get; set; }
|
public long ReceiptId { get; set; }
|
||||||
public string FileName { get; set; } = "";
|
public string FileName { get; set; } = "";
|
||||||
public DateTime UploadedAtUtc { get; set; }
|
public DateTime UploadedAtUtc { get; set; }
|
||||||
public long? TransactionId { get; set; }
|
public long? TransactionId { get; set; }
|
||||||
public string? TransactionName { get; set; }
|
public string? TransactionName { get; set; }
|
||||||
public string Reason { get; set; } = "";
|
public string Reason { get; set; } = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
public class BulkUploadResult
|
public class BulkUploadResult
|
||||||
{
|
{
|
||||||
public List<BulkUploadItem> Uploaded { get; init; } = new();
|
public List<BulkUploadItem> Uploaded { get; init; } = new();
|
||||||
public List<BulkUploadFailure> Failed { get; init; } = new();
|
public List<BulkUploadFailure> Failed { get; init; } = new();
|
||||||
public int TotalCount => Uploaded.Count + Failed.Count;
|
public int TotalCount => Uploaded.Count + Failed.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
public class BulkUploadItem
|
public class BulkUploadItem
|
||||||
{
|
{
|
||||||
public long ReceiptId { get; set; }
|
public long ReceiptId { get; set; }
|
||||||
public string FileName { get; set; } = "";
|
public string FileName { get; set; } = "";
|
||||||
public List<DuplicateWarning> DuplicateWarnings { get; set; } = new();
|
public List<DuplicateWarning> DuplicateWarnings { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class BulkUploadFailure
|
public class BulkUploadFailure
|
||||||
{
|
{
|
||||||
public string FileName { get; set; } = "";
|
public string FileName { get; set; } = "";
|
||||||
public string ErrorMessage { get; set; } = "";
|
public string ErrorMessage { get; set; } = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,237 +1,237 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using MoneyMap.Data;
|
using MoneyMap.Data;
|
||||||
using MoneyMap.Models;
|
using MoneyMap.Models;
|
||||||
|
|
||||||
namespace MoneyMap.Services;
|
namespace MoneyMap.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Service for matching receipts to transactions based on date, merchant, and amount.
|
/// Service for matching receipts to transactions based on date, merchant, and amount.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IReceiptMatchingService
|
public interface IReceiptMatchingService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Finds matching transactions for a receipt based on date range, merchant name,
|
/// Finds matching transactions for a receipt based on date range, merchant name,
|
||||||
/// and amount tolerance. Returns transactions sorted by relevance.
|
/// and amount tolerance. Returns transactions sorted by relevance.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<TransactionMatch>> FindMatchingTransactionsAsync(ReceiptMatchCriteria criteria);
|
Task<List<TransactionMatch>> FindMatchingTransactionsAsync(ReceiptMatchCriteria criteria);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets a set of transaction IDs that already have receipts mapped to them.
|
/// Gets a set of transaction IDs that already have receipts mapped to them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<HashSet<long>> GetTransactionIdsWithReceiptsAsync();
|
Task<HashSet<long>> GetTransactionIdsWithReceiptsAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ReceiptMatchingService : IReceiptMatchingService
|
public class ReceiptMatchingService : IReceiptMatchingService
|
||||||
{
|
{
|
||||||
private readonly MoneyMapContext _db;
|
private readonly MoneyMapContext _db;
|
||||||
|
|
||||||
public ReceiptMatchingService(MoneyMapContext db)
|
public ReceiptMatchingService(MoneyMapContext db)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<HashSet<long>> GetTransactionIdsWithReceiptsAsync()
|
public async Task<HashSet<long>> GetTransactionIdsWithReceiptsAsync()
|
||||||
{
|
{
|
||||||
var transactionIds = await _db.Receipts
|
var transactionIds = await _db.Receipts
|
||||||
.Where(r => r.TransactionId != null)
|
.Where(r => r.TransactionId != null)
|
||||||
.Select(r => r.TransactionId!.Value)
|
.Select(r => r.TransactionId!.Value)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return new HashSet<long>(transactionIds);
|
return new HashSet<long>(transactionIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<TransactionMatch>> FindMatchingTransactionsAsync(ReceiptMatchCriteria criteria)
|
public async Task<List<TransactionMatch>> FindMatchingTransactionsAsync(ReceiptMatchCriteria criteria)
|
||||||
{
|
{
|
||||||
var query = _db.Transactions
|
var query = _db.Transactions
|
||||||
.Include(t => t.Card)
|
.Include(t => t.Card)
|
||||||
.Include(t => t.Account)
|
.Include(t => t.Account)
|
||||||
.Include(t => t.Merchant)
|
.Include(t => t.Merchant)
|
||||||
.Where(t => !criteria.ExcludeTransactionIds.Contains(t.Id))
|
.Where(t => !criteria.ExcludeTransactionIds.Contains(t.Id))
|
||||||
.AsQueryable();
|
.AsQueryable();
|
||||||
|
|
||||||
// Apply date filtering based on receipt type
|
// Apply date filtering based on receipt type
|
||||||
query = ApplyDateFilter(query, criteria);
|
query = ApplyDateFilter(query, criteria);
|
||||||
|
|
||||||
// Get all candidates within date range
|
// Get all candidates within date range
|
||||||
var candidates = await query.ToListAsync();
|
var candidates = await query.ToListAsync();
|
||||||
|
|
||||||
// Sort by merchant/name relevance using word matching
|
// Sort by merchant/name relevance using word matching
|
||||||
if (!string.IsNullOrWhiteSpace(criteria.MerchantName))
|
if (!string.IsNullOrWhiteSpace(criteria.MerchantName))
|
||||||
{
|
{
|
||||||
candidates = SortByMerchantRelevance(candidates, criteria.MerchantName);
|
candidates = SortByMerchantRelevance(candidates, criteria.MerchantName);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// No merchant filter, just sort by date
|
// No merchant filter, just sort by date
|
||||||
candidates = candidates
|
candidates = candidates
|
||||||
.OrderByDescending(t => t.Date)
|
.OrderByDescending(t => t.Date)
|
||||||
.ThenByDescending(t => t.Id)
|
.ThenByDescending(t => t.Id)
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by amount (±10% tolerance) if receipt has a total
|
// Filter by amount (±10% tolerance) if receipt has a total
|
||||||
if (criteria.Total.HasValue)
|
if (criteria.Total.HasValue)
|
||||||
{
|
{
|
||||||
candidates = FilterByAmountTolerance(candidates, criteria.Total.Value);
|
candidates = FilterByAmountTolerance(candidates, criteria.Total.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to match results with scoring
|
// Convert to match results with scoring
|
||||||
var matches = ConvertToMatches(candidates, criteria);
|
var matches = ConvertToMatches(candidates, criteria);
|
||||||
|
|
||||||
// If no date-filtered matches, fall back to recent transactions
|
// If no date-filtered matches, fall back to recent transactions
|
||||||
if (!matches.Any() && !criteria.ReceiptDate.HasValue)
|
if (!matches.Any() && !criteria.ReceiptDate.HasValue)
|
||||||
{
|
{
|
||||||
matches = await GetFallbackMatches(criteria.ExcludeTransactionIds);
|
matches = await GetFallbackMatches(criteria.ExcludeTransactionIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
return matches;
|
return matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IQueryable<Transaction> ApplyDateFilter(IQueryable<Transaction> query, ReceiptMatchCriteria criteria)
|
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
|
// For bills with due dates: use range from bill date to due date + 5 days
|
||||||
// (to account for auto-pay processing delays, weekends, etc.)
|
// (to account for auto-pay processing delays, weekends, etc.)
|
||||||
if (criteria.ReceiptDate.HasValue && criteria.DueDate.HasValue)
|
if (criteria.ReceiptDate.HasValue && criteria.DueDate.HasValue)
|
||||||
{
|
{
|
||||||
var minDate = criteria.ReceiptDate.Value;
|
var minDate = criteria.ReceiptDate.Value;
|
||||||
var maxDate = criteria.DueDate.Value.AddDays(5);
|
var maxDate = criteria.DueDate.Value.AddDays(5);
|
||||||
return query.Where(t => t.Date >= minDate && t.Date <= maxDate);
|
return query.Where(t => t.Date >= minDate && t.Date <= maxDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For regular receipts: use +/- 3 days
|
// For regular receipts: use +/- 3 days
|
||||||
if (criteria.ReceiptDate.HasValue)
|
if (criteria.ReceiptDate.HasValue)
|
||||||
{
|
{
|
||||||
var minDate = criteria.ReceiptDate.Value.AddDays(-3);
|
var minDate = criteria.ReceiptDate.Value.AddDays(-3);
|
||||||
var maxDate = criteria.ReceiptDate.Value.AddDays(3);
|
var maxDate = criteria.ReceiptDate.Value.AddDays(3);
|
||||||
return query.Where(t => t.Date >= minDate && t.Date <= maxDate);
|
return query.Where(t => t.Date >= minDate && t.Date <= maxDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
return query;
|
return query;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<Transaction> SortByMerchantRelevance(List<Transaction> candidates, string merchantName)
|
private static List<Transaction> SortByMerchantRelevance(List<Transaction> candidates, string merchantName)
|
||||||
{
|
{
|
||||||
var receiptWords = merchantName.ToLower().Split(new[] { ' ', '-', '_', '.' }, StringSplitOptions.RemoveEmptyEntries);
|
var receiptWords = merchantName.ToLower().Split(new[] { ' ', '-', '_', '.' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
return candidates
|
return candidates
|
||||||
.OrderByDescending(t =>
|
.OrderByDescending(t =>
|
||||||
{
|
{
|
||||||
var merchantNameLower = t.Merchant?.Name?.ToLower() ?? "";
|
var merchantNameLower = t.Merchant?.Name?.ToLower() ?? "";
|
||||||
var transactionNameLower = t.Name?.ToLower() ?? "";
|
var transactionNameLower = t.Name?.ToLower() ?? "";
|
||||||
|
|
||||||
// Exact match gets highest score
|
// Exact match gets highest score
|
||||||
if (merchantNameLower == merchantName.ToLower() || transactionNameLower == merchantName.ToLower())
|
if (merchantNameLower == merchantName.ToLower() || transactionNameLower == merchantName.ToLower())
|
||||||
return 1000;
|
return 1000;
|
||||||
|
|
||||||
// Count matching words
|
// Count matching words
|
||||||
var merchantWords = merchantNameLower.Split(new[] { ' ', '-', '_', '.' }, StringSplitOptions.RemoveEmptyEntries);
|
var merchantWords = merchantNameLower.Split(new[] { ' ', '-', '_', '.' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
var transactionWords = transactionNameLower.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 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)));
|
var transactionMatches = receiptWords.Count(rw => transactionWords.Any(tw => tw.Contains(rw) || rw.Contains(tw)));
|
||||||
|
|
||||||
// Return the higher match count
|
// Return the higher match count
|
||||||
return Math.Max(merchantMatches * 10, transactionMatches * 10);
|
return Math.Max(merchantMatches * 10, transactionMatches * 10);
|
||||||
})
|
})
|
||||||
.ThenByDescending(t => t.Date)
|
.ThenByDescending(t => t.Date)
|
||||||
.ThenByDescending(t => t.Id)
|
.ThenByDescending(t => t.Id)
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<Transaction> FilterByAmountTolerance(List<Transaction> candidates, decimal total)
|
private static List<Transaction> FilterByAmountTolerance(List<Transaction> candidates, decimal total)
|
||||||
{
|
{
|
||||||
var receiptTotal = Math.Round(Math.Abs(total), 2);
|
var receiptTotal = Math.Round(Math.Abs(total), 2);
|
||||||
var tolerance = receiptTotal * 0.10m; // 10% tolerance
|
var tolerance = receiptTotal * 0.10m; // 10% tolerance
|
||||||
var minAmount = receiptTotal - tolerance;
|
var minAmount = receiptTotal - tolerance;
|
||||||
var maxAmount = receiptTotal + tolerance;
|
var maxAmount = receiptTotal + tolerance;
|
||||||
|
|
||||||
return candidates
|
return candidates
|
||||||
.Where(t =>
|
.Where(t =>
|
||||||
{
|
{
|
||||||
var transactionAmount = Math.Round(Math.Abs(t.Amount), 2);
|
var transactionAmount = Math.Round(Math.Abs(t.Amount), 2);
|
||||||
return transactionAmount >= minAmount && transactionAmount <= maxAmount;
|
return transactionAmount >= minAmount && transactionAmount <= maxAmount;
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<TransactionMatch> ConvertToMatches(List<Transaction> candidates, ReceiptMatchCriteria criteria)
|
private static List<TransactionMatch> ConvertToMatches(List<Transaction> candidates, ReceiptMatchCriteria criteria)
|
||||||
{
|
{
|
||||||
return candidates.Select(t =>
|
return candidates.Select(t =>
|
||||||
{
|
{
|
||||||
var match = new TransactionMatch
|
var match = new TransactionMatch
|
||||||
{
|
{
|
||||||
Id = t.Id,
|
Id = t.Id,
|
||||||
Date = t.Date,
|
Date = t.Date,
|
||||||
Name = t.Name,
|
Name = t.Name,
|
||||||
Amount = t.Amount,
|
Amount = t.Amount,
|
||||||
MerchantName = t.Merchant?.Name,
|
MerchantName = t.Merchant?.Name,
|
||||||
PaymentMethod = t.PaymentMethodLabel,
|
PaymentMethod = t.PaymentMethodLabel,
|
||||||
IsExactAmount = false,
|
IsExactAmount = false,
|
||||||
IsCloseAmount = false
|
IsCloseAmount = false
|
||||||
};
|
};
|
||||||
|
|
||||||
// Amount matching flags
|
// Amount matching flags
|
||||||
if (criteria.Total.HasValue)
|
if (criteria.Total.HasValue)
|
||||||
{
|
{
|
||||||
var receiptTotal = Math.Round(Math.Abs(criteria.Total.Value), 2);
|
var receiptTotal = Math.Round(Math.Abs(criteria.Total.Value), 2);
|
||||||
var transactionAmount = Math.Round(Math.Abs(t.Amount), 2);
|
var transactionAmount = Math.Round(Math.Abs(t.Amount), 2);
|
||||||
match.IsExactAmount = transactionAmount == receiptTotal;
|
match.IsExactAmount = transactionAmount == receiptTotal;
|
||||||
var tolerance = receiptTotal * 0.10m;
|
var tolerance = receiptTotal * 0.10m;
|
||||||
match.IsCloseAmount = !match.IsExactAmount && Math.Abs(transactionAmount - receiptTotal) <= tolerance;
|
match.IsCloseAmount = !match.IsExactAmount && Math.Abs(transactionAmount - receiptTotal) <= tolerance;
|
||||||
}
|
}
|
||||||
|
|
||||||
return match;
|
return match;
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<TransactionMatch>> GetFallbackMatches(HashSet<long> excludeIds)
|
private async Task<List<TransactionMatch>> GetFallbackMatches(HashSet<long> excludeIds)
|
||||||
{
|
{
|
||||||
return await _db.Transactions
|
return await _db.Transactions
|
||||||
.Include(t => t.Card)
|
.Include(t => t.Card)
|
||||||
.Include(t => t.Account)
|
.Include(t => t.Account)
|
||||||
.Include(t => t.Merchant)
|
.Include(t => t.Merchant)
|
||||||
.Where(t => !excludeIds.Contains(t.Id))
|
.Where(t => !excludeIds.Contains(t.Id))
|
||||||
.OrderByDescending(t => t.Date)
|
.OrderByDescending(t => t.Date)
|
||||||
.ThenByDescending(t => t.Id)
|
.ThenByDescending(t => t.Id)
|
||||||
.Take(50)
|
.Take(50)
|
||||||
.Select(t => new TransactionMatch
|
.Select(t => new TransactionMatch
|
||||||
{
|
{
|
||||||
Id = t.Id,
|
Id = t.Id,
|
||||||
Date = t.Date,
|
Date = t.Date,
|
||||||
Name = t.Name,
|
Name = t.Name,
|
||||||
Amount = t.Amount,
|
Amount = t.Amount,
|
||||||
MerchantName = t.Merchant != null ? t.Merchant.Name : null,
|
MerchantName = t.Merchant != null ? t.Merchant.Name : null,
|
||||||
PaymentMethod = t.PaymentMethodLabel,
|
PaymentMethod = t.PaymentMethodLabel,
|
||||||
IsExactAmount = false,
|
IsExactAmount = false,
|
||||||
IsCloseAmount = false
|
IsCloseAmount = false
|
||||||
})
|
})
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Criteria for matching receipts to transactions.
|
/// Criteria for matching receipts to transactions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ReceiptMatchCriteria
|
public class ReceiptMatchCriteria
|
||||||
{
|
{
|
||||||
public DateTime? ReceiptDate { get; set; }
|
public DateTime? ReceiptDate { get; set; }
|
||||||
public DateTime? DueDate { get; set; }
|
public DateTime? DueDate { get; set; }
|
||||||
public decimal? Total { get; set; }
|
public decimal? Total { get; set; }
|
||||||
public string? MerchantName { get; set; }
|
public string? MerchantName { get; set; }
|
||||||
public HashSet<long> ExcludeTransactionIds { get; set; } = new();
|
public HashSet<long> ExcludeTransactionIds { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents a transaction that matches a receipt, with scoring information.
|
/// Represents a transaction that matches a receipt, with scoring information.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class TransactionMatch
|
public class TransactionMatch
|
||||||
{
|
{
|
||||||
public long Id { get; set; }
|
public long Id { get; set; }
|
||||||
public DateTime Date { get; set; }
|
public DateTime Date { get; set; }
|
||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
public decimal Amount { get; set; }
|
public decimal Amount { get; set; }
|
||||||
public string? MerchantName { get; set; }
|
public string? MerchantName { get; set; }
|
||||||
public string PaymentMethod { get; set; } = "";
|
public string PaymentMethod { get; set; } = "";
|
||||||
public bool IsExactAmount { get; set; }
|
public bool IsExactAmount { get; set; }
|
||||||
public bool IsCloseAmount { get; set; }
|
public bool IsCloseAmount { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +1,56 @@
|
|||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
|
|
||||||
namespace MoneyMap.Services
|
namespace MoneyMap.Services
|
||||||
{
|
{
|
||||||
public interface IReceiptParseQueue
|
public interface IReceiptParseQueue
|
||||||
{
|
{
|
||||||
ValueTask EnqueueAsync(long receiptId, CancellationToken ct = default);
|
ValueTask EnqueueAsync(long receiptId, CancellationToken ct = default);
|
||||||
ValueTask EnqueueManyAsync(IEnumerable<long> receiptIds, CancellationToken ct = default);
|
ValueTask EnqueueManyAsync(IEnumerable<long> receiptIds, CancellationToken ct = default);
|
||||||
ValueTask<long> DequeueAsync(CancellationToken ct);
|
ValueTask<long> DequeueAsync(CancellationToken ct);
|
||||||
int QueueLength { get; }
|
int QueueLength { get; }
|
||||||
long? CurrentlyProcessingId { get; }
|
long? CurrentlyProcessingId { get; }
|
||||||
void SetCurrentlyProcessing(long? receiptId);
|
void SetCurrentlyProcessing(long? receiptId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ReceiptParseQueue : IReceiptParseQueue
|
public class ReceiptParseQueue : IReceiptParseQueue
|
||||||
{
|
{
|
||||||
private readonly Channel<long> _channel = Channel.CreateUnbounded<long>(
|
private readonly Channel<long> _channel = Channel.CreateUnbounded<long>(
|
||||||
new UnboundedChannelOptions { SingleReader = true });
|
new UnboundedChannelOptions { SingleReader = true });
|
||||||
|
|
||||||
private long _currentlyProcessingId;
|
private long _currentlyProcessingId;
|
||||||
|
|
||||||
public int QueueLength => _channel.Reader.Count;
|
public int QueueLength => _channel.Reader.Count;
|
||||||
|
|
||||||
public long? CurrentlyProcessingId
|
public long? CurrentlyProcessingId
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
var val = Interlocked.Read(ref _currentlyProcessingId);
|
var val = Interlocked.Read(ref _currentlyProcessingId);
|
||||||
return val == 0 ? null : val;
|
return val == 0 ? null : val;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetCurrentlyProcessing(long? receiptId)
|
public void SetCurrentlyProcessing(long? receiptId)
|
||||||
{
|
{
|
||||||
Interlocked.Exchange(ref _currentlyProcessingId, receiptId ?? 0);
|
Interlocked.Exchange(ref _currentlyProcessingId, receiptId ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask EnqueueAsync(long receiptId, CancellationToken ct = default)
|
public async ValueTask EnqueueAsync(long receiptId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await _channel.Writer.WriteAsync(receiptId, ct);
|
await _channel.Writer.WriteAsync(receiptId, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask EnqueueManyAsync(IEnumerable<long> receiptIds, CancellationToken ct = default)
|
public async ValueTask EnqueueManyAsync(IEnumerable<long> receiptIds, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
foreach (var id in receiptIds)
|
foreach (var id in receiptIds)
|
||||||
{
|
{
|
||||||
await _channel.Writer.WriteAsync(id, ct);
|
await _channel.Writer.WriteAsync(id, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask<long> DequeueAsync(CancellationToken ct)
|
public async ValueTask<long> DequeueAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
return await _channel.Reader.ReadAsync(ct);
|
return await _channel.Reader.ReadAsync(ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,81 +1,81 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using MoneyMap.Data;
|
using MoneyMap.Data;
|
||||||
using MoneyMap.Models;
|
using MoneyMap.Models;
|
||||||
|
|
||||||
namespace MoneyMap.Services;
|
namespace MoneyMap.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Service for retrieving reference/lookup data used in dropdowns and filters.
|
/// Service for retrieving reference/lookup data used in dropdowns and filters.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IReferenceDataService
|
public interface IReferenceDataService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets all distinct categories from transactions, sorted alphabetically.
|
/// Gets all distinct categories from transactions, sorted alphabetically.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<string>> GetAvailableCategoriesAsync();
|
Task<List<string>> GetAvailableCategoriesAsync();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets all merchants, sorted by name.
|
/// Gets all merchants, sorted by name.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<Merchant>> GetAvailableMerchantsAsync();
|
Task<List<Merchant>> GetAvailableMerchantsAsync();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets all cards with optional account information included.
|
/// Gets all cards with optional account information included.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<Card>> GetAvailableCardsAsync(bool includeAccount = true);
|
Task<List<Card>> GetAvailableCardsAsync(bool includeAccount = true);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets all accounts, sorted by institution and last4.
|
/// Gets all accounts, sorted by institution and last4.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<Account>> GetAvailableAccountsAsync();
|
Task<List<Account>> GetAvailableAccountsAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ReferenceDataService : IReferenceDataService
|
public class ReferenceDataService : IReferenceDataService
|
||||||
{
|
{
|
||||||
private readonly MoneyMapContext _db;
|
private readonly MoneyMapContext _db;
|
||||||
|
|
||||||
public ReferenceDataService(MoneyMapContext db)
|
public ReferenceDataService(MoneyMapContext db)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<string>> GetAvailableCategoriesAsync()
|
public async Task<List<string>> GetAvailableCategoriesAsync()
|
||||||
{
|
{
|
||||||
return await _db.Transactions
|
return await _db.Transactions
|
||||||
.Select(t => t.Category ?? "")
|
.Select(t => t.Category ?? "")
|
||||||
.Where(c => !string.IsNullOrWhiteSpace(c))
|
.Where(c => !string.IsNullOrWhiteSpace(c))
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.OrderBy(c => c)
|
.OrderBy(c => c)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<Merchant>> GetAvailableMerchantsAsync()
|
public async Task<List<Merchant>> GetAvailableMerchantsAsync()
|
||||||
{
|
{
|
||||||
return await _db.Merchants
|
return await _db.Merchants
|
||||||
.OrderBy(m => m.Name)
|
.OrderBy(m => m.Name)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<Card>> GetAvailableCardsAsync(bool includeAccount = true)
|
public async Task<List<Card>> GetAvailableCardsAsync(bool includeAccount = true)
|
||||||
{
|
{
|
||||||
var query = _db.Cards.AsQueryable();
|
var query = _db.Cards.AsQueryable();
|
||||||
|
|
||||||
if (includeAccount)
|
if (includeAccount)
|
||||||
{
|
{
|
||||||
query = query.Include(c => c.Account);
|
query = query.Include(c => c.Account);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await query
|
return await query
|
||||||
.OrderBy(c => c.Owner)
|
.OrderBy(c => c.Owner)
|
||||||
.ThenBy(c => c.Last4)
|
.ThenBy(c => c.Last4)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<Account>> GetAvailableAccountsAsync()
|
public async Task<List<Account>> GetAvailableAccountsAsync()
|
||||||
{
|
{
|
||||||
return await _db.Accounts
|
return await _db.Accounts
|
||||||
.OrderBy(a => a.Institution)
|
.OrderBy(a => a.Institution)
|
||||||
.ThenBy(a => a.Last4)
|
.ThenBy(a => a.Last4)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,467 +1,467 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using MoneyMap.Data;
|
using MoneyMap.Data;
|
||||||
using MoneyMap.Models;
|
using MoneyMap.Models;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace MoneyMap.Services;
|
namespace MoneyMap.Services;
|
||||||
|
|
||||||
public interface ITransactionAICategorizer
|
public interface ITransactionAICategorizer
|
||||||
{
|
{
|
||||||
Task<AICategoryProposal?> ProposeCategorizationAsync(Transaction transaction, string? model = null);
|
Task<AICategoryProposal?> ProposeCategorizationAsync(Transaction transaction, string? model = null);
|
||||||
Task<List<AICategoryProposal>> ProposeBatchCategorizationAsync(List<Transaction> transactions, string? model = null);
|
Task<List<AICategoryProposal>> ProposeBatchCategorizationAsync(List<Transaction> transactions, string? model = null);
|
||||||
Task<ApplyProposalResult> ApplyProposalAsync(long transactionId, AICategoryProposal proposal, bool createRule = true);
|
Task<ApplyProposalResult> ApplyProposalAsync(long transactionId, AICategoryProposal proposal, bool createRule = true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class TransactionAICategorizer : ITransactionAICategorizer
|
public class TransactionAICategorizer : ITransactionAICategorizer
|
||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly MoneyMapContext _db;
|
private readonly MoneyMapContext _db;
|
||||||
private readonly IConfiguration _config;
|
private readonly IConfiguration _config;
|
||||||
private readonly LlamaCppVisionClient _llamaClient;
|
private readonly LlamaCppVisionClient _llamaClient;
|
||||||
private readonly ILogger<TransactionAICategorizer> _logger;
|
private readonly ILogger<TransactionAICategorizer> _logger;
|
||||||
|
|
||||||
public TransactionAICategorizer(
|
public TransactionAICategorizer(
|
||||||
HttpClient httpClient,
|
HttpClient httpClient,
|
||||||
MoneyMapContext db,
|
MoneyMapContext db,
|
||||||
IConfiguration config,
|
IConfiguration config,
|
||||||
LlamaCppVisionClient llamaClient,
|
LlamaCppVisionClient llamaClient,
|
||||||
ILogger<TransactionAICategorizer> logger)
|
ILogger<TransactionAICategorizer> logger)
|
||||||
{
|
{
|
||||||
_httpClient = httpClient;
|
_httpClient = httpClient;
|
||||||
_db = db;
|
_db = db;
|
||||||
_config = config;
|
_config = config;
|
||||||
_llamaClient = llamaClient;
|
_llamaClient = llamaClient;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AICategoryProposal?> ProposeCategorizationAsync(Transaction transaction, string? model = null)
|
public async Task<AICategoryProposal?> ProposeCategorizationAsync(Transaction transaction, string? model = null)
|
||||||
{
|
{
|
||||||
var selectedModel = model ?? _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini";
|
var selectedModel = model ?? _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini";
|
||||||
var prompt = await BuildPromptAsync(transaction);
|
var prompt = await BuildPromptAsync(transaction);
|
||||||
|
|
||||||
var response = await CallModelAsync(prompt, selectedModel);
|
var response = await CallModelAsync(prompt, selectedModel);
|
||||||
|
|
||||||
if (response == null)
|
if (response == null)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
return new AICategoryProposal
|
return new AICategoryProposal
|
||||||
{
|
{
|
||||||
TransactionId = transaction.Id,
|
TransactionId = transaction.Id,
|
||||||
Category = response.Category ?? "",
|
Category = response.Category ?? "",
|
||||||
CanonicalMerchant = response.CanonicalMerchant,
|
CanonicalMerchant = response.CanonicalMerchant,
|
||||||
Pattern = response.Pattern,
|
Pattern = response.Pattern,
|
||||||
Priority = response.Priority,
|
Priority = response.Priority,
|
||||||
Confidence = response.Confidence,
|
Confidence = response.Confidence,
|
||||||
Reasoning = response.Reasoning,
|
Reasoning = response.Reasoning,
|
||||||
CreateRule = response.Confidence >= 0.7m // High confidence = auto-create rule
|
CreateRule = response.Confidence >= 0.7m // High confidence = auto-create rule
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<AICategoryProposal>> ProposeBatchCategorizationAsync(List<Transaction> transactions, string? model = null)
|
public async Task<List<AICategoryProposal>> ProposeBatchCategorizationAsync(List<Transaction> transactions, string? model = null)
|
||||||
{
|
{
|
||||||
var proposals = new List<AICategoryProposal>();
|
var proposals = new List<AICategoryProposal>();
|
||||||
|
|
||||||
// Pre-fetch existing categories and all rules once to avoid concurrent DbContext access
|
// Pre-fetch existing categories and all rules once to avoid concurrent DbContext access
|
||||||
var existingCategories = await _db.CategoryMappings
|
var existingCategories = await _db.CategoryMappings
|
||||||
.Select(m => m.Category)
|
.Select(m => m.Category)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.OrderBy(c => c)
|
.OrderBy(c => c)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var allRules = await _db.CategoryMappings
|
var allRules = await _db.CategoryMappings
|
||||||
.Include(m => m.Merchant)
|
.Include(m => m.Merchant)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
// Process transactions sequentially to avoid DbContext concurrency issues
|
// Process transactions sequentially to avoid DbContext concurrency issues
|
||||||
foreach (var transaction in transactions)
|
foreach (var transaction in transactions)
|
||||||
{
|
{
|
||||||
var result = await ProposeCategorizationWithCategoriesAsync(transaction, existingCategories, allRules, model);
|
var result = await ProposeCategorizationWithCategoriesAsync(transaction, existingCategories, allRules, model);
|
||||||
if (result != null)
|
if (result != null)
|
||||||
proposals.Add(result);
|
proposals.Add(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
return proposals;
|
return proposals;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<AICategoryProposal?> ProposeCategorizationWithCategoriesAsync(
|
private async Task<AICategoryProposal?> ProposeCategorizationWithCategoriesAsync(
|
||||||
Transaction transaction,
|
Transaction transaction,
|
||||||
List<string> existingCategories,
|
List<string> existingCategories,
|
||||||
List<CategoryMapping> allRules,
|
List<CategoryMapping> allRules,
|
||||||
string? model = null)
|
string? model = null)
|
||||||
{
|
{
|
||||||
var selectedModel = model ?? _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini";
|
var selectedModel = model ?? _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini";
|
||||||
|
|
||||||
// Find rules whose pattern matches this transaction name
|
// Find rules whose pattern matches this transaction name
|
||||||
var matchingRules = allRules
|
var matchingRules = allRules
|
||||||
.Where(r => transaction.Name.Contains(r.Pattern, StringComparison.OrdinalIgnoreCase))
|
.Where(r => transaction.Name.Contains(r.Pattern, StringComparison.OrdinalIgnoreCase))
|
||||||
.OrderByDescending(r => r.Priority)
|
.OrderByDescending(r => r.Priority)
|
||||||
.ThenByDescending(r => r.Pattern.Length) // Prefer more specific patterns
|
.ThenByDescending(r => r.Pattern.Length) // Prefer more specific patterns
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var prompt = BuildPromptWithCategoriesAndRules(transaction, existingCategories, matchingRules);
|
var prompt = BuildPromptWithCategoriesAndRules(transaction, existingCategories, matchingRules);
|
||||||
|
|
||||||
var response = await CallModelAsync(prompt, selectedModel);
|
var response = await CallModelAsync(prompt, selectedModel);
|
||||||
|
|
||||||
if (response == null)
|
if (response == null)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
return new AICategoryProposal
|
return new AICategoryProposal
|
||||||
{
|
{
|
||||||
TransactionId = transaction.Id,
|
TransactionId = transaction.Id,
|
||||||
Category = response.Category ?? "",
|
Category = response.Category ?? "",
|
||||||
CanonicalMerchant = response.CanonicalMerchant,
|
CanonicalMerchant = response.CanonicalMerchant,
|
||||||
Pattern = response.Pattern,
|
Pattern = response.Pattern,
|
||||||
Priority = response.Priority,
|
Priority = response.Priority,
|
||||||
Confidence = response.Confidence,
|
Confidence = response.Confidence,
|
||||||
Reasoning = response.Reasoning,
|
Reasoning = response.Reasoning,
|
||||||
CreateRule = response.Confidence >= 0.7m
|
CreateRule = response.Confidence >= 0.7m
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ApplyProposalResult> ApplyProposalAsync(long transactionId, AICategoryProposal proposal, bool createRule = true)
|
public async Task<ApplyProposalResult> ApplyProposalAsync(long transactionId, AICategoryProposal proposal, bool createRule = true)
|
||||||
{
|
{
|
||||||
var transaction = await _db.Transactions.FindAsync(transactionId);
|
var transaction = await _db.Transactions.FindAsync(transactionId);
|
||||||
if (transaction == null)
|
if (transaction == null)
|
||||||
return new ApplyProposalResult { Success = false, ErrorMessage = "Transaction not found" };
|
return new ApplyProposalResult { Success = false, ErrorMessage = "Transaction not found" };
|
||||||
|
|
||||||
// Update transaction category
|
// Update transaction category
|
||||||
transaction.Category = proposal.Category;
|
transaction.Category = proposal.Category;
|
||||||
|
|
||||||
// Handle merchant
|
// Handle merchant
|
||||||
if (!string.IsNullOrWhiteSpace(proposal.CanonicalMerchant))
|
if (!string.IsNullOrWhiteSpace(proposal.CanonicalMerchant))
|
||||||
{
|
{
|
||||||
var merchant = await _db.Merchants.FirstOrDefaultAsync(m => m.Name == proposal.CanonicalMerchant);
|
var merchant = await _db.Merchants.FirstOrDefaultAsync(m => m.Name == proposal.CanonicalMerchant);
|
||||||
if (merchant == null)
|
if (merchant == null)
|
||||||
{
|
{
|
||||||
merchant = new Merchant { Name = proposal.CanonicalMerchant };
|
merchant = new Merchant { Name = proposal.CanonicalMerchant };
|
||||||
_db.Merchants.Add(merchant);
|
_db.Merchants.Add(merchant);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
transaction.MerchantId = merchant.Id;
|
transaction.MerchantId = merchant.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ruleCreated = false;
|
bool ruleCreated = false;
|
||||||
bool ruleUpdated = false;
|
bool ruleUpdated = false;
|
||||||
|
|
||||||
// Create or update category mapping rule if requested
|
// Create or update category mapping rule if requested
|
||||||
if (createRule && !string.IsNullOrWhiteSpace(proposal.Pattern))
|
if (createRule && !string.IsNullOrWhiteSpace(proposal.Pattern))
|
||||||
{
|
{
|
||||||
var existingRule = await _db.CategoryMappings
|
var existingRule = await _db.CategoryMappings
|
||||||
.FirstOrDefaultAsync(m => m.Pattern == proposal.Pattern);
|
.FirstOrDefaultAsync(m => m.Pattern == proposal.Pattern);
|
||||||
|
|
||||||
if (existingRule == null)
|
if (existingRule == null)
|
||||||
{
|
{
|
||||||
var newMapping = new CategoryMapping
|
var newMapping = new CategoryMapping
|
||||||
{
|
{
|
||||||
Category = proposal.Category,
|
Category = proposal.Category,
|
||||||
Pattern = proposal.Pattern,
|
Pattern = proposal.Pattern,
|
||||||
MerchantId = transaction.MerchantId,
|
MerchantId = transaction.MerchantId,
|
||||||
Priority = proposal.Priority,
|
Priority = proposal.Priority,
|
||||||
Confidence = proposal.Confidence,
|
Confidence = proposal.Confidence,
|
||||||
CreatedBy = "AI",
|
CreatedBy = "AI",
|
||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
_db.CategoryMappings.Add(newMapping);
|
_db.CategoryMappings.Add(newMapping);
|
||||||
ruleCreated = true;
|
ruleCreated = true;
|
||||||
}
|
}
|
||||||
else if (existingRule.Category != proposal.Category)
|
else if (existingRule.Category != proposal.Category)
|
||||||
{
|
{
|
||||||
existingRule.Category = proposal.Category;
|
existingRule.Category = proposal.Category;
|
||||||
existingRule.MerchantId = transaction.MerchantId;
|
existingRule.MerchantId = transaction.MerchantId;
|
||||||
existingRule.Priority = proposal.Priority;
|
existingRule.Priority = proposal.Priority;
|
||||||
existingRule.Confidence = proposal.Confidence;
|
existingRule.Confidence = proposal.Confidence;
|
||||||
existingRule.CreatedBy = "AI";
|
existingRule.CreatedBy = "AI";
|
||||||
existingRule.CreatedAt = DateTime.UtcNow;
|
existingRule.CreatedAt = DateTime.UtcNow;
|
||||||
ruleUpdated = true;
|
ruleUpdated = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return new ApplyProposalResult
|
return new ApplyProposalResult
|
||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
RuleCreated = ruleCreated,
|
RuleCreated = ruleCreated,
|
||||||
RuleUpdated = ruleUpdated
|
RuleUpdated = ruleUpdated
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> BuildPromptAsync(Transaction transaction)
|
private async Task<string> BuildPromptAsync(Transaction transaction)
|
||||||
{
|
{
|
||||||
// Get existing categories from database for better suggestions
|
// Get existing categories from database for better suggestions
|
||||||
var existingCategories = await _db.CategoryMappings
|
var existingCategories = await _db.CategoryMappings
|
||||||
.Select(m => m.Category)
|
.Select(m => m.Category)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.OrderBy(c => c)
|
.OrderBy(c => c)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
// Load all rules and find matches in memory (pattern-in-name is hard to express in SQL)
|
// Load all rules and find matches in memory (pattern-in-name is hard to express in SQL)
|
||||||
var allRules = await _db.CategoryMappings
|
var allRules = await _db.CategoryMappings
|
||||||
.Include(m => m.Merchant)
|
.Include(m => m.Merchant)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var matchingRules = allRules
|
var matchingRules = allRules
|
||||||
.Where(r => transaction.Name.Contains(r.Pattern, StringComparison.OrdinalIgnoreCase))
|
.Where(r => transaction.Name.Contains(r.Pattern, StringComparison.OrdinalIgnoreCase))
|
||||||
.OrderByDescending(r => r.Priority)
|
.OrderByDescending(r => r.Priority)
|
||||||
.ThenByDescending(r => r.Pattern.Length)
|
.ThenByDescending(r => r.Pattern.Length)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return BuildPromptWithCategoriesAndRules(transaction, existingCategories, matchingRules);
|
return BuildPromptWithCategoriesAndRules(transaction, existingCategories, matchingRules);
|
||||||
}
|
}
|
||||||
|
|
||||||
private string BuildPromptWithCategories(Transaction transaction, List<string> existingCategories)
|
private string BuildPromptWithCategories(Transaction transaction, List<string> existingCategories)
|
||||||
{
|
{
|
||||||
return BuildPromptWithCategoriesAndRules(transaction, existingCategories, new List<CategoryMapping>());
|
return BuildPromptWithCategoriesAndRules(transaction, existingCategories, new List<CategoryMapping>());
|
||||||
}
|
}
|
||||||
|
|
||||||
private string BuildPromptWithCategoriesAndRules(Transaction transaction, List<string> existingCategories, List<CategoryMapping> matchingRules)
|
private string BuildPromptWithCategoriesAndRules(Transaction transaction, List<string> existingCategories, List<CategoryMapping> matchingRules)
|
||||||
{
|
{
|
||||||
var categoryList = existingCategories.Any()
|
var categoryList = existingCategories.Any()
|
||||||
? string.Join(", ", existingCategories)
|
? string.Join(", ", existingCategories)
|
||||||
: "Restaurants, Fast Food, Coffee Shop, Groceries, Convenience Store, Gas & Auto, Online shopping, Health, Entertainment, Utilities, Banking, Insurance";
|
: "Restaurants, Fast Food, Coffee Shop, Groceries, Convenience Store, Gas & Auto, Online shopping, Health, Entertainment, Utilities, Banking, Insurance";
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
sb.AppendLine("Analyze this financial transaction and suggest a category and merchant name.");
|
sb.AppendLine("Analyze this financial transaction and suggest a category and merchant name.");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
sb.AppendLine("Transaction Details:");
|
sb.AppendLine("Transaction Details:");
|
||||||
sb.AppendLine($"- Name: \"{transaction.Name}\"");
|
sb.AppendLine($"- Name: \"{transaction.Name}\"");
|
||||||
sb.AppendLine($"- Memo: \"{transaction.Memo}\"");
|
sb.AppendLine($"- Memo: \"{transaction.Memo}\"");
|
||||||
sb.AppendLine($"- Amount: {transaction.Amount:C}");
|
sb.AppendLine($"- Amount: {transaction.Amount:C}");
|
||||||
sb.AppendLine($"- Date: {transaction.Date:yyyy-MM-dd}");
|
sb.AppendLine($"- Date: {transaction.Date:yyyy-MM-dd}");
|
||||||
sb.AppendLine($"- Type: {(transaction.IsCredit ? "Credit/Income" : "Debit/Expense")}");
|
sb.AppendLine($"- Type: {(transaction.IsCredit ? "Credit/Income" : "Debit/Expense")}");
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(transaction.Category))
|
if (!string.IsNullOrWhiteSpace(transaction.Category))
|
||||||
sb.AppendLine($"- Current Category: \"{transaction.Category}\"");
|
sb.AppendLine($"- Current Category: \"{transaction.Category}\"");
|
||||||
|
|
||||||
if (transaction.Merchant != null)
|
if (transaction.Merchant != null)
|
||||||
sb.AppendLine($"- Current Merchant: \"{transaction.Merchant.Name}\"");
|
sb.AppendLine($"- Current Merchant: \"{transaction.Merchant.Name}\"");
|
||||||
|
|
||||||
if (transaction.Card != null)
|
if (transaction.Card != null)
|
||||||
sb.AppendLine($"- Card: {transaction.Card.Owner} - ****{transaction.Card.Last4}");
|
sb.AppendLine($"- Card: {transaction.Card.Owner} - ****{transaction.Card.Last4}");
|
||||||
|
|
||||||
if (transaction.Account != null)
|
if (transaction.Account != null)
|
||||||
sb.AppendLine($"- Account: {transaction.Account.DisplayLabel}");
|
sb.AppendLine($"- Account: {transaction.Account.DisplayLabel}");
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(transaction.Notes))
|
if (!string.IsNullOrWhiteSpace(transaction.Notes))
|
||||||
sb.AppendLine($"- Notes: \"{transaction.Notes}\"");
|
sb.AppendLine($"- Notes: \"{transaction.Notes}\"");
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(transaction.Last4))
|
if (!string.IsNullOrWhiteSpace(transaction.Last4))
|
||||||
sb.AppendLine($"- Last 4 digits: {transaction.Last4}");
|
sb.AppendLine($"- Last 4 digits: {transaction.Last4}");
|
||||||
|
|
||||||
if (transaction.IsTransfer)
|
if (transaction.IsTransfer)
|
||||||
sb.AppendLine($"- Transfer to: {transaction.TransferToAccount?.DisplayLabel ?? "Unknown"}");
|
sb.AppendLine($"- Transfer to: {transaction.TransferToAccount?.DisplayLabel ?? "Unknown"}");
|
||||||
|
|
||||||
// Include matching rules so the AI respects existing mappings
|
// Include matching rules so the AI respects existing mappings
|
||||||
if (matchingRules.Any())
|
if (matchingRules.Any())
|
||||||
{
|
{
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
sb.AppendLine("EXISTING RULES that match this transaction (you MUST use these categories unless clearly wrong):");
|
sb.AppendLine("EXISTING RULES that match this transaction (you MUST use these categories unless clearly wrong):");
|
||||||
foreach (var rule in matchingRules)
|
foreach (var rule in matchingRules)
|
||||||
{
|
{
|
||||||
var createdBy = rule.CreatedBy ?? "Unknown";
|
var createdBy = rule.CreatedBy ?? "Unknown";
|
||||||
var merchantName = rule.Merchant?.Name;
|
var merchantName = rule.Merchant?.Name;
|
||||||
sb.Append($" - Pattern \"{rule.Pattern}\" → Category \"{rule.Category}\"");
|
sb.Append($" - Pattern \"{rule.Pattern}\" → Category \"{rule.Category}\"");
|
||||||
if (!string.IsNullOrWhiteSpace(merchantName))
|
if (!string.IsNullOrWhiteSpace(merchantName))
|
||||||
sb.Append($", Merchant \"{merchantName}\"");
|
sb.Append($", Merchant \"{merchantName}\"");
|
||||||
sb.AppendLine($" (created by {createdBy})");
|
sb.AppendLine($" (created by {createdBy})");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
sb.AppendLine($"Existing categories in this system: {categoryList}");
|
sb.AppendLine($"Existing categories in this system: {categoryList}");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
sb.AppendLine("Provide your analysis in JSON format:");
|
sb.AppendLine("Provide your analysis in JSON format:");
|
||||||
sb.AppendLine("{");
|
sb.AppendLine("{");
|
||||||
sb.AppendLine(" \"category\": \"Category name\",");
|
sb.AppendLine(" \"category\": \"Category name\",");
|
||||||
sb.AppendLine(" \"canonical_merchant\": \"Clean merchant name (e.g., 'Walmart' from 'WAL-MART #1234')\",");
|
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(" \"pattern\": \"EXACT substring from the transaction Name that identifies this merchant\",");
|
||||||
sb.AppendLine(" \"priority\": 0,");
|
sb.AppendLine(" \"priority\": 0,");
|
||||||
sb.AppendLine(" \"confidence\": 0.85,");
|
sb.AppendLine(" \"confidence\": 0.85,");
|
||||||
sb.AppendLine(" \"reasoning\": \"Brief explanation\"");
|
sb.AppendLine(" \"reasoning\": \"Brief explanation\"");
|
||||||
sb.AppendLine("}");
|
sb.AppendLine("}");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
sb.AppendLine("Guidelines:");
|
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("- 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("- 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("- 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("- 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.");
|
sb.AppendLine("- Return ONLY valid JSON, no additional text.");
|
||||||
|
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<AICategorizationResponse?> CallModelAsync(string prompt, string model)
|
private async Task<AICategorizationResponse?> CallModelAsync(string prompt, string model)
|
||||||
{
|
{
|
||||||
if (model.StartsWith("llamacpp:", StringComparison.OrdinalIgnoreCase))
|
if (model.StartsWith("llamacpp:", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Using LlamaCpp for transaction categorization with model {Model}", model);
|
_logger.LogInformation("Using LlamaCpp for transaction categorization with model {Model}", model);
|
||||||
return await CallLlamaCppAsync(prompt, model);
|
return await CallLlamaCppAsync(prompt, model);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default to OpenAI
|
// Default to OpenAI
|
||||||
var apiKey = _config["OpenAI:ApiKey"] ?? Environment.GetEnvironmentVariable("OPENAI_API_KEY");
|
var apiKey = _config["OpenAI:ApiKey"] ?? Environment.GetEnvironmentVariable("OPENAI_API_KEY");
|
||||||
if (string.IsNullOrWhiteSpace(apiKey))
|
if (string.IsNullOrWhiteSpace(apiKey))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("OpenAI API key not configured");
|
_logger.LogWarning("OpenAI API key not configured");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Using OpenAI for transaction categorization with model {Model}", model);
|
_logger.LogInformation("Using OpenAI for transaction categorization with model {Model}", model);
|
||||||
return await CallOpenAIAsync(apiKey, prompt, model);
|
return await CallOpenAIAsync(apiKey, prompt, model);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<AICategorizationResponse?> CallOpenAIAsync(string apiKey, string prompt, string model = "gpt-4o-mini")
|
private async Task<AICategorizationResponse?> CallOpenAIAsync(string apiKey, string prompt, string model = "gpt-4o-mini")
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var requestBody = new
|
var requestBody = new
|
||||||
{
|
{
|
||||||
model = model,
|
model = model,
|
||||||
messages = new[]
|
messages = new[]
|
||||||
{
|
{
|
||||||
new { role = "system", content = "You are a financial transaction categorization expert. Always respond with valid JSON only." },
|
new { role = "system", content = "You are a financial transaction categorization expert. Always respond with valid JSON only." },
|
||||||
new { role = "user", content = prompt }
|
new { role = "user", content = prompt }
|
||||||
},
|
},
|
||||||
temperature = 0.1,
|
temperature = 0.1,
|
||||||
max_tokens = 300
|
max_tokens = 300
|
||||||
};
|
};
|
||||||
|
|
||||||
var request = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/chat/completions");
|
var request = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/chat/completions");
|
||||||
request.Headers.Add("Authorization", $"Bearer {apiKey}");
|
request.Headers.Add("Authorization", $"Bearer {apiKey}");
|
||||||
request.Content = new StringContent(
|
request.Content = new StringContent(
|
||||||
JsonSerializer.Serialize(requestBody),
|
JsonSerializer.Serialize(requestBody),
|
||||||
Encoding.UTF8,
|
Encoding.UTF8,
|
||||||
"application/json"
|
"application/json"
|
||||||
);
|
);
|
||||||
|
|
||||||
var response = await _httpClient.SendAsync(request);
|
var response = await _httpClient.SendAsync(request);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
var apiResponse = JsonSerializer.Deserialize<OpenAIChatResponse>(json);
|
var apiResponse = JsonSerializer.Deserialize<OpenAIChatResponse>(json);
|
||||||
|
|
||||||
if (apiResponse?.Choices == null || apiResponse.Choices.Length == 0)
|
if (apiResponse?.Choices == null || apiResponse.Choices.Length == 0)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var content = OpenAIToolUseHelper.CleanJsonResponse(apiResponse.Choices[0].Message?.Content);
|
var content = OpenAIToolUseHelper.CleanJsonResponse(apiResponse.Choices[0].Message?.Content);
|
||||||
if (string.IsNullOrWhiteSpace(content))
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
return JsonSerializer.Deserialize<AICategorizationResponse>(content, new JsonSerializerOptions
|
return JsonSerializer.Deserialize<AICategorizationResponse>(content, new JsonSerializerOptions
|
||||||
{
|
{
|
||||||
PropertyNameCaseInsensitive = true
|
PropertyNameCaseInsensitive = true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (HttpRequestException ex)
|
catch (HttpRequestException ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "OpenAI API request failed: {Message}", ex.Message);
|
_logger.LogError(ex, "OpenAI API request failed: {Message}", ex.Message);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
catch (JsonException ex)
|
catch (JsonException ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to parse OpenAI response JSON: {Message}", ex.Message);
|
_logger.LogError(ex, "Failed to parse OpenAI response JSON: {Message}", ex.Message);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Unexpected error calling OpenAI API: {Message}", ex.Message);
|
_logger.LogError(ex, "Unexpected error calling OpenAI API: {Message}", ex.Message);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<AICategorizationResponse?> CallLlamaCppAsync(string prompt, string? model = null)
|
private async Task<AICategorizationResponse?> CallLlamaCppAsync(string prompt, string? model = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var selectedModel = model ?? _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini";
|
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 systemPrompt = "You are a financial transaction categorization expert. Always respond with valid JSON only.";
|
||||||
var fullPrompt = $"{systemPrompt}\n\n{prompt}";
|
var fullPrompt = $"{systemPrompt}\n\n{prompt}";
|
||||||
|
|
||||||
var result = await _llamaClient.SendTextPromptAsync(fullPrompt, selectedModel);
|
var result = await _llamaClient.SendTextPromptAsync(fullPrompt, selectedModel);
|
||||||
|
|
||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("LlamaCpp categorization failed: {Error}", result.ErrorMessage);
|
_logger.LogWarning("LlamaCpp categorization failed: {Error}", result.ErrorMessage);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return JsonSerializer.Deserialize<AICategorizationResponse>(result.Content ?? "", new JsonSerializerOptions
|
return JsonSerializer.Deserialize<AICategorizationResponse>(result.Content ?? "", new JsonSerializerOptions
|
||||||
{
|
{
|
||||||
PropertyNameCaseInsensitive = true
|
PropertyNameCaseInsensitive = true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (JsonException ex)
|
catch (JsonException ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to parse LlamaCpp response JSON: {Message}", ex.Message);
|
_logger.LogError(ex, "Failed to parse LlamaCpp response JSON: {Message}", ex.Message);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Unexpected error calling LlamaCpp: {Message}", ex.Message);
|
_logger.LogError(ex, "Unexpected error calling LlamaCpp: {Message}", ex.Message);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI API response models
|
// OpenAI API response models
|
||||||
private class OpenAIChatResponse
|
private class OpenAIChatResponse
|
||||||
{
|
{
|
||||||
[JsonPropertyName("choices")]
|
[JsonPropertyName("choices")]
|
||||||
public Choice[]? Choices { get; set; }
|
public Choice[]? Choices { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private class Choice
|
private class Choice
|
||||||
{
|
{
|
||||||
[JsonPropertyName("message")]
|
[JsonPropertyName("message")]
|
||||||
public Message? Message { get; set; }
|
public Message? Message { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private class Message
|
private class Message
|
||||||
{
|
{
|
||||||
[JsonPropertyName("content")]
|
[JsonPropertyName("content")]
|
||||||
public string? Content { get; set; }
|
public string? Content { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private class AICategorizationResponse
|
private class AICategorizationResponse
|
||||||
{
|
{
|
||||||
[JsonPropertyName("category")]
|
[JsonPropertyName("category")]
|
||||||
public string? Category { get; set; }
|
public string? Category { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("canonical_merchant")]
|
[JsonPropertyName("canonical_merchant")]
|
||||||
public string? CanonicalMerchant { get; set; }
|
public string? CanonicalMerchant { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("pattern")]
|
[JsonPropertyName("pattern")]
|
||||||
public string? Pattern { get; set; }
|
public string? Pattern { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("priority")]
|
[JsonPropertyName("priority")]
|
||||||
public int Priority { get; set; }
|
public int Priority { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("confidence")]
|
[JsonPropertyName("confidence")]
|
||||||
public decimal Confidence { get; set; }
|
public decimal Confidence { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("reasoning")]
|
[JsonPropertyName("reasoning")]
|
||||||
public string? Reasoning { get; set; }
|
public string? Reasoning { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AICategoryProposal
|
public class AICategoryProposal
|
||||||
{
|
{
|
||||||
public long TransactionId { get; set; }
|
public long TransactionId { get; set; }
|
||||||
public string Category { get; set; } = "";
|
public string Category { get; set; } = "";
|
||||||
public string? CanonicalMerchant { get; set; }
|
public string? CanonicalMerchant { get; set; }
|
||||||
public string? Pattern { get; set; }
|
public string? Pattern { get; set; }
|
||||||
public int Priority { get; set; }
|
public int Priority { get; set; }
|
||||||
public decimal Confidence { get; set; }
|
public decimal Confidence { get; set; }
|
||||||
public string? Reasoning { get; set; }
|
public string? Reasoning { get; set; }
|
||||||
public bool CreateRule { get; set; }
|
public bool CreateRule { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ApplyProposalResult
|
public class ApplyProposalResult
|
||||||
{
|
{
|
||||||
public bool Success { get; set; }
|
public bool Success { get; set; }
|
||||||
public bool RuleCreated { get; set; }
|
public bool RuleCreated { get; set; }
|
||||||
public bool RuleUpdated { get; set; }
|
public bool RuleUpdated { get; set; }
|
||||||
public string? ErrorMessage { get; set; }
|
public string? ErrorMessage { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,263 +1,263 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using MoneyMap.Data;
|
using MoneyMap.Data;
|
||||||
using MoneyMap.Models;
|
using MoneyMap.Models;
|
||||||
|
|
||||||
namespace MoneyMap.Services
|
namespace MoneyMap.Services
|
||||||
{
|
{
|
||||||
|
|
||||||
public interface ITransactionCategorizer
|
public interface ITransactionCategorizer
|
||||||
{
|
{
|
||||||
Task<CategorizationResult> CategorizeAsync(string merchantName, decimal? amount = null);
|
Task<CategorizationResult> CategorizeAsync(string merchantName, decimal? amount = null);
|
||||||
Task<List<CategoryMapping>> GetAllMappingsAsync();
|
Task<List<CategoryMapping>> GetAllMappingsAsync();
|
||||||
Task SeedDefaultMappingsAsync();
|
Task SeedDefaultMappingsAsync();
|
||||||
void InvalidateMappingsCache();
|
void InvalidateMappingsCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CategorizationResult
|
public class CategorizationResult
|
||||||
{
|
{
|
||||||
public string Category { get; set; } = string.Empty;
|
public string Category { get; set; } = string.Empty;
|
||||||
public int? MerchantId { get; set; }
|
public int? MerchantId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Service Implementation =====
|
// ===== Service Implementation =====
|
||||||
|
|
||||||
public class TransactionCategorizer : ITransactionCategorizer
|
public class TransactionCategorizer : ITransactionCategorizer
|
||||||
{
|
{
|
||||||
private readonly MoneyMapContext _db;
|
private readonly MoneyMapContext _db;
|
||||||
private readonly IMemoryCache _cache;
|
private readonly IMemoryCache _cache;
|
||||||
private const decimal GasStationThreshold = -20m;
|
private const decimal GasStationThreshold = -20m;
|
||||||
private const string MappingsCacheKey = "CategoryMappings";
|
private const string MappingsCacheKey = "CategoryMappings";
|
||||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10);
|
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10);
|
||||||
|
|
||||||
public TransactionCategorizer(MoneyMapContext db, IMemoryCache cache)
|
public TransactionCategorizer(MoneyMapContext db, IMemoryCache cache)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void InvalidateMappingsCache()
|
public void InvalidateMappingsCache()
|
||||||
{
|
{
|
||||||
_cache.Remove(MappingsCacheKey);
|
_cache.Remove(MappingsCacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<CategorizationResult> CategorizeAsync(string merchantName, decimal? amount = null)
|
public async Task<CategorizationResult> CategorizeAsync(string merchantName, decimal? amount = null)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(merchantName))
|
if (string.IsNullOrWhiteSpace(merchantName))
|
||||||
return new CategorizationResult();
|
return new CategorizationResult();
|
||||||
|
|
||||||
var merchantUpper = merchantName.ToUpperInvariant();
|
var merchantUpper = merchantName.ToUpperInvariant();
|
||||||
|
|
||||||
// Get cached mappings or load from database
|
// Get cached mappings or load from database
|
||||||
var mappings = await GetCachedMappingsAsync();
|
var mappings = await GetCachedMappingsAsync();
|
||||||
|
|
||||||
// Special case: Gas stations with small purchases
|
// Special case: Gas stations with small purchases
|
||||||
if (amount.HasValue && amount.Value > GasStationThreshold)
|
if (amount.HasValue && amount.Value > GasStationThreshold)
|
||||||
{
|
{
|
||||||
var gasMapping = mappings.FirstOrDefault(m =>
|
var gasMapping = mappings.FirstOrDefault(m =>
|
||||||
m.Category == "Gas & Auto" &&
|
m.Category == "Gas & Auto" &&
|
||||||
merchantUpper.Contains(m.Pattern.ToUpperInvariant()));
|
merchantUpper.Contains(m.Pattern.ToUpperInvariant()));
|
||||||
|
|
||||||
if (gasMapping != null)
|
if (gasMapping != null)
|
||||||
return new CategorizationResult
|
return new CategorizationResult
|
||||||
{
|
{
|
||||||
Category = "Convenience Store",
|
Category = "Convenience Store",
|
||||||
MerchantId = gasMapping.MerchantId
|
MerchantId = gasMapping.MerchantId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check each category's patterns
|
// Check each category's patterns
|
||||||
foreach (var mapping in mappings)
|
foreach (var mapping in mappings)
|
||||||
{
|
{
|
||||||
if (merchantUpper.Contains(mapping.Pattern.ToUpperInvariant()))
|
if (merchantUpper.Contains(mapping.Pattern.ToUpperInvariant()))
|
||||||
return new CategorizationResult
|
return new CategorizationResult
|
||||||
{
|
{
|
||||||
Category = mapping.Category,
|
Category = mapping.Category,
|
||||||
MerchantId = mapping.MerchantId
|
MerchantId = mapping.MerchantId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return new CategorizationResult(); // No match - needs manual categorization
|
return new CategorizationResult(); // No match - needs manual categorization
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<CategoryMapping>> GetCachedMappingsAsync()
|
private async Task<List<CategoryMapping>> GetCachedMappingsAsync()
|
||||||
{
|
{
|
||||||
if (_cache.TryGetValue(MappingsCacheKey, out List<CategoryMapping>? cachedMappings) && cachedMappings != null)
|
if (_cache.TryGetValue(MappingsCacheKey, out List<CategoryMapping>? cachedMappings) && cachedMappings != null)
|
||||||
{
|
{
|
||||||
return cachedMappings;
|
return cachedMappings;
|
||||||
}
|
}
|
||||||
|
|
||||||
var mappings = await _db.CategoryMappings
|
var mappings = await _db.CategoryMappings
|
||||||
.OrderByDescending(m => m.Priority)
|
.OrderByDescending(m => m.Priority)
|
||||||
.ThenBy(m => m.Category)
|
.ThenBy(m => m.Category)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
_cache.Set(MappingsCacheKey, mappings, CacheDuration);
|
_cache.Set(MappingsCacheKey, mappings, CacheDuration);
|
||||||
return mappings;
|
return mappings;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<CategoryMapping>> GetAllMappingsAsync()
|
public async Task<List<CategoryMapping>> GetAllMappingsAsync()
|
||||||
{
|
{
|
||||||
var mappings = await GetCachedMappingsAsync();
|
var mappings = await GetCachedMappingsAsync();
|
||||||
return mappings.OrderBy(m => m.Category).ThenByDescending(m => m.Priority).ToList();
|
return mappings.OrderBy(m => m.Category).ThenByDescending(m => m.Priority).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SeedDefaultMappingsAsync()
|
public async Task SeedDefaultMappingsAsync()
|
||||||
{
|
{
|
||||||
// Check if mappings already exist
|
// Check if mappings already exist
|
||||||
if (await _db.CategoryMappings.AnyAsync())
|
if (await _db.CategoryMappings.AnyAsync())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var defaultMappings = GetDefaultMappings();
|
var defaultMappings = GetDefaultMappings();
|
||||||
|
|
||||||
_db.CategoryMappings.AddRange(defaultMappings);
|
_db.CategoryMappings.AddRange(defaultMappings);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<CategoryMapping> GetDefaultMappings()
|
private static List<CategoryMapping> GetDefaultMappings()
|
||||||
{
|
{
|
||||||
var mappings = new List<CategoryMapping>();
|
var mappings = new List<CategoryMapping>();
|
||||||
|
|
||||||
// Online Shopping
|
// Online Shopping
|
||||||
AddMappings("Online shopping", mappings,
|
AddMappings("Online shopping", mappings,
|
||||||
"AMAZON MKTPL", "AMAZON.COM", "BATHANDBODYWORKS", "BATH AND BODY",
|
"AMAZON MKTPL", "AMAZON.COM", "BATHANDBODYWORKS", "BATH AND BODY",
|
||||||
"SEPHORA.COM", "ULTA.COM", "WWW.KOHLS.COM", "GAPOUTLET.COM",
|
"SEPHORA.COM", "ULTA.COM", "WWW.KOHLS.COM", "GAPOUTLET.COM",
|
||||||
"NIKE.COM", "HOMEDEPOT.COM", "TEMU.COM", "APPLE.COM",
|
"NIKE.COM", "HOMEDEPOT.COM", "TEMU.COM", "APPLE.COM",
|
||||||
"JOURNEYS.COM", "DECKERS*UGG", "YUNNANSOURCINGUS", "TARGET.COM");
|
"JOURNEYS.COM", "DECKERS*UGG", "YUNNANSOURCINGUS", "TARGET.COM");
|
||||||
|
|
||||||
// Walmart
|
// Walmart
|
||||||
AddMappings("Walmart Online", mappings, "WALMART.COM");
|
AddMappings("Walmart Online", mappings, "WALMART.COM");
|
||||||
AddMappings("Walmart Pickup/Grocery", mappings, "WALMART.C ", "DEBIT PURCHASE WALMART.C");
|
AddMappings("Walmart Pickup/Grocery", mappings, "WALMART.C ", "DEBIT PURCHASE WALMART.C");
|
||||||
|
|
||||||
// Pizza
|
// Pizza
|
||||||
AddMappings("Pizza", mappings,
|
AddMappings("Pizza", mappings,
|
||||||
"CHICAGOS PIZZA", "PIZZA KING", "DOMINO", "BIG BOYZ",
|
"CHICAGOS PIZZA", "PIZZA KING", "DOMINO", "BIG BOYZ",
|
||||||
"PAPA JOHN", "PIZZA 3.14", "HUNGRY HOWIES");
|
"PAPA JOHN", "PIZZA 3.14", "HUNGRY HOWIES");
|
||||||
|
|
||||||
// Retail Stores
|
// Retail Stores
|
||||||
AddMappings("Brick/mortar store", mappings,
|
AddMappings("Brick/mortar store", mappings,
|
||||||
"DOLLAR-GENERAL", "DOLLAR GENERAL", "DOLLAR TREE", "GOODWILL STORE",
|
"DOLLAR-GENERAL", "DOLLAR GENERAL", "DOLLAR TREE", "GOODWILL STORE",
|
||||||
"WAL-MART", "WM SUPERCENTER", "KROGER", "TARGET", "LOWES",
|
"WAL-MART", "WM SUPERCENTER", "KROGER", "TARGET", "LOWES",
|
||||||
"GILLMAN HOME CEN", "TRACTOR SUPPLY", "FIVE BELOW", "CLAIRE'S", "SAVE-A-LOT");
|
"GILLMAN HOME CEN", "TRACTOR SUPPLY", "FIVE BELOW", "CLAIRE'S", "SAVE-A-LOT");
|
||||||
|
|
||||||
// Restaurants
|
// Restaurants
|
||||||
AddMappings("Eat out / Restaurants", mappings,
|
AddMappings("Eat out / Restaurants", mappings,
|
||||||
"KUNKELS DRIVE IN", "MCDONALD", "STARBUCKS", "ASIAN DELIGHT",
|
"KUNKELS DRIVE IN", "MCDONALD", "STARBUCKS", "ASIAN DELIGHT",
|
||||||
"STACKS PANCAKE", "WENDY", "SUBWAY", "OLIVE GARDEN", "CRACKER BARREL",
|
"STACKS PANCAKE", "WENDY", "SUBWAY", "OLIVE GARDEN", "CRACKER BARREL",
|
||||||
"RED LOBSTER", "NO. 9 GRILL", "LEES FAMOUS", "OLE ROOSTE",
|
"RED LOBSTER", "NO. 9 GRILL", "LEES FAMOUS", "OLE ROOSTE",
|
||||||
"EL CABALLO", "WAFFLE HOUSE", "GULF COAST BURG", "LAKEVIEW RESTAUR",
|
"EL CABALLO", "WAFFLE HOUSE", "GULF COAST BURG", "LAKEVIEW RESTAUR",
|
||||||
"ARBY", "BURGER KING", "DAIRY QUEEN", "TACO BELL", "DUNKIN", "CRUMBL");
|
"ARBY", "BURGER KING", "DAIRY QUEEN", "TACO BELL", "DUNKIN", "CRUMBL");
|
||||||
|
|
||||||
// School
|
// School
|
||||||
AddMappings("School", mappings,
|
AddMappings("School", mappings,
|
||||||
"INTER-STATE STUD", "CREATIVE STEPS", "CPP*CONNERSVILLE");
|
"INTER-STATE STUD", "CREATIVE STEPS", "CPP*CONNERSVILLE");
|
||||||
|
|
||||||
// Health
|
// Health
|
||||||
AddMappings("Health", mappings,
|
AddMappings("Health", mappings,
|
||||||
"MEDICENTER", "REID HEALTH", "PHARMACY", "CVS", "WALGREENS",
|
"MEDICENTER", "REID HEALTH", "PHARMACY", "CVS", "WALGREENS",
|
||||||
"WHITEWATER EYE", "GIESTING FAMILY DENTIS");
|
"WHITEWATER EYE", "GIESTING FAMILY DENTIS");
|
||||||
|
|
||||||
// Gas & Auto (higher priority for special handling)
|
// Gas & Auto (higher priority for special handling)
|
||||||
AddMappings("Gas & Auto", mappings, 100,
|
AddMappings("Gas & Auto", mappings, 100,
|
||||||
"SPEEDWAY", "MARATHON", "SHELL OIL", "BP#", "SUNOCO",
|
"SPEEDWAY", "MARATHON", "SHELL OIL", "BP#", "SUNOCO",
|
||||||
"WASH & LU", "WASH LUB", "CAR WASH", "MCDIVITT FAR",
|
"WASH & LU", "WASH LUB", "CAR WASH", "MCDIVITT FAR",
|
||||||
"COUNTY TIRE", "BROOKVILLE SHELL", "BUC-EE'S", "CIRCLE K", "MAIN STREET QUIC");
|
"COUNTY TIRE", "BROOKVILLE SHELL", "BUC-EE'S", "CIRCLE K", "MAIN STREET QUIC");
|
||||||
|
|
||||||
// Utilities
|
// Utilities
|
||||||
AddMappings("Utilities/Services", mappings,
|
AddMappings("Utilities/Services", mappings,
|
||||||
"SMARTSTOP", "VZWRLSS", "VERIZON", "COMCAST", "XFINIT",
|
"SMARTSTOP", "VZWRLSS", "VERIZON", "COMCAST", "XFINIT",
|
||||||
"US MOBILE", "WHITEWATER VALLE", "RUMPKE");
|
"US MOBILE", "WHITEWATER VALLE", "RUMPKE");
|
||||||
|
|
||||||
// Entertainment
|
// Entertainment
|
||||||
AddMappings("Entertainment", mappings,
|
AddMappings("Entertainment", mappings,
|
||||||
"SHOWTIME CINEMA", "SHOWPLACE CINEMA", "RICHMOND CIV", "KINDLE",
|
"SHOWTIME CINEMA", "SHOWPLACE CINEMA", "RICHMOND CIV", "KINDLE",
|
||||||
"GOOGLE *Google S", "NINTENDO", "HLU*HULU", "HULU", "NETFLIX",
|
"GOOGLE *Google S", "NINTENDO", "HLU*HULU", "HULU", "NETFLIX",
|
||||||
"SPOTIFY", "STEAMGAMES", "WL *STEAM PURCHASE", "ETSY", "GEEK-HUB");
|
"SPOTIFY", "STEAMGAMES", "WL *STEAM PURCHASE", "ETSY", "GEEK-HUB");
|
||||||
|
|
||||||
// Banking (high priority to catch these first)
|
// Banking (high priority to catch these first)
|
||||||
AddMappings("Banking", mappings, 200,
|
AddMappings("Banking", mappings, 200,
|
||||||
"ATM WITHDRAWAL", "ATM FEE", "MOBILE BANKING ADVANCE",
|
"ATM WITHDRAWAL", "ATM FEE", "MOBILE BANKING ADVANCE",
|
||||||
"MOBILE BANKING PAYMENT", "MOBILE BANKING TRANSFER", "OVERDRAFT",
|
"MOBILE BANKING PAYMENT", "MOBILE BANKING TRANSFER", "OVERDRAFT",
|
||||||
"MONTHLY MAINTENANCE FEE", "OD PROTECTION", "RESERVE LINE",
|
"MONTHLY MAINTENANCE FEE", "OD PROTECTION", "RESERVE LINE",
|
||||||
"FRGN TRANS FEE", "START SCHEDULED TRANSFER");
|
"FRGN TRANS FEE", "START SCHEDULED TRANSFER");
|
||||||
|
|
||||||
// Mortgage
|
// Mortgage
|
||||||
AddMappings("Mortgage", mappings, "WAYNE BANK");
|
AddMappings("Mortgage", mappings, "WAYNE BANK");
|
||||||
|
|
||||||
// Car Payment
|
// Car Payment
|
||||||
AddMappings("Car Payment", mappings, "UNION SAVINGS AN");
|
AddMappings("Car Payment", mappings, "UNION SAVINGS AN");
|
||||||
|
|
||||||
// Convenience Store
|
// Convenience Store
|
||||||
AddMappings("Convenience Store", mappings,
|
AddMappings("Convenience Store", mappings,
|
||||||
"PAVEYS COUNTRY", "CAMBRIDGE CITY M", "WHITEWATER QUICK");
|
"PAVEYS COUNTRY", "CAMBRIDGE CITY M", "WHITEWATER QUICK");
|
||||||
|
|
||||||
// Income (high priority)
|
// Income (high priority)
|
||||||
AddMappings("Income", mappings, 200,
|
AddMappings("Income", mappings, 200,
|
||||||
"MOBILE CHECK DEPOSIT", "ELECTRONIC DEPOSIT", "IRS TREAS",
|
"MOBILE CHECK DEPOSIT", "ELECTRONIC DEPOSIT", "IRS TREAS",
|
||||||
"RPA PA", "REWARDS REDEEMED");
|
"RPA PA", "REWARDS REDEEMED");
|
||||||
|
|
||||||
// Taxes
|
// Taxes
|
||||||
AddMappings("Taxes", mappings, "MYERS INCOME TAX");
|
AddMappings("Taxes", mappings, "MYERS INCOME TAX");
|
||||||
|
|
||||||
// Insurance
|
// Insurance
|
||||||
AddMappings("Insurance", mappings,
|
AddMappings("Insurance", mappings,
|
||||||
"BOSTON MUTUAL", "IND FARMERS INS", "GERBER LIFE INS");
|
"BOSTON MUTUAL", "IND FARMERS INS", "GERBER LIFE INS");
|
||||||
|
|
||||||
// Credit Card Payment (high priority to catch before Banking)
|
// Credit Card Payment (high priority to catch before Banking)
|
||||||
AddMappings("Credit Card Payment", mappings, 200,
|
AddMappings("Credit Card Payment", mappings, 200,
|
||||||
"PAYMENT TO CREDIT CARD", "CAPITAL ONE", "MOBILE PAYMENT THANK YOU");
|
"PAYMENT TO CREDIT CARD", "CAPITAL ONE", "MOBILE PAYMENT THANK YOU");
|
||||||
|
|
||||||
// Ice Cream
|
// Ice Cream
|
||||||
AddMappings("Ice Cream / Treats", mappings, "DAIRY TWIST", "URANUS FUDGE");
|
AddMappings("Ice Cream / Treats", mappings, "DAIRY TWIST", "URANUS FUDGE");
|
||||||
|
|
||||||
// Government
|
// Government
|
||||||
AddMappings("Government/DMV", mappings, "IN BMV", "KY-IN RIVERLINK");
|
AddMappings("Government/DMV", mappings, "IN BMV", "KY-IN RIVERLINK");
|
||||||
|
|
||||||
// Home Services
|
// Home Services
|
||||||
AddMappings("Home Services", mappings, "DUNGAN PLUMBING");
|
AddMappings("Home Services", mappings, "DUNGAN PLUMBING");
|
||||||
|
|
||||||
// Special Occasions
|
// Special Occasions
|
||||||
AddMappings("Special Occasions", mappings,
|
AddMappings("Special Occasions", mappings,
|
||||||
"CLARKS FLOWER", "THE CAKE BAK", "DOUGHERTY OR");
|
"CLARKS FLOWER", "THE CAKE BAK", "DOUGHERTY OR");
|
||||||
|
|
||||||
// Home Improvement
|
// Home Improvement
|
||||||
AddMappings("Home Improvement", mappings,
|
AddMappings("Home Improvement", mappings,
|
||||||
"SHERWIN-WILLIAMS", "PAINTERS SUPPLY", "MENARDS", "LOWES #00907",
|
"SHERWIN-WILLIAMS", "PAINTERS SUPPLY", "MENARDS", "LOWES #00907",
|
||||||
"123FILTER", "O-RING STORE");
|
"123FILTER", "O-RING STORE");
|
||||||
|
|
||||||
// Software/Subscriptions
|
// Software/Subscriptions
|
||||||
AddMappings("Software/Subscriptions", mappings,
|
AddMappings("Software/Subscriptions", mappings,
|
||||||
"GOOGLE *ChatGPT", "CLAUDE.AI", "OPENAI", "NAME-CHEAP",
|
"GOOGLE *ChatGPT", "CLAUDE.AI", "OPENAI", "NAME-CHEAP",
|
||||||
"AMAZON PRIME*", "BITWARDEN", "GOOGLE *Shopping List");
|
"AMAZON PRIME*", "BITWARDEN", "GOOGLE *Shopping List");
|
||||||
|
|
||||||
return mappings;
|
return mappings;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddMappings(string category, List<CategoryMapping> mappings, params string[] patterns)
|
private static void AddMappings(string category, List<CategoryMapping> mappings, params string[] patterns)
|
||||||
{
|
{
|
||||||
AddMappings(category, mappings, 0, patterns);
|
AddMappings(category, mappings, 0, patterns);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddMappings(string category, List<CategoryMapping> mappings, int priority, params string[] patterns)
|
private static void AddMappings(string category, List<CategoryMapping> mappings, int priority, params string[] patterns)
|
||||||
{
|
{
|
||||||
foreach (var pattern in patterns)
|
foreach (var pattern in patterns)
|
||||||
{
|
{
|
||||||
mappings.Add(new CategoryMapping
|
mappings.Add(new CategoryMapping
|
||||||
{
|
{
|
||||||
Category = category,
|
Category = category,
|
||||||
Pattern = pattern,
|
Pattern = pattern,
|
||||||
MerchantId = null, // Will be set by users via UI
|
MerchantId = null, // Will be set by users via UI
|
||||||
Priority = priority
|
Priority = priority
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Database Migration =====
|
// ===== Database Migration =====
|
||||||
// Add this to your DbContext:
|
// Add this to your DbContext:
|
||||||
// public DbSet<CategoryMapping> CategoryMappings { get; set; }
|
// public DbSet<CategoryMapping> CategoryMappings { get; set; }
|
||||||
//
|
//
|
||||||
// Then create a migration:
|
// Then create a migration:
|
||||||
// dotnet ef migrations add AddCategoryMappings
|
// dotnet ef migrations add AddCategoryMappings
|
||||||
// dotnet ef database update
|
// dotnet ef database update
|
||||||
}
|
}
|
||||||
@@ -1,37 +1,37 @@
|
|||||||
using MoneyMap.Models;
|
using MoneyMap.Models;
|
||||||
|
|
||||||
namespace MoneyMap.Services
|
namespace MoneyMap.Services
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Helper class for filtering transactions in queries
|
/// Helper class for filtering transactions in queries
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class TransactionFilters
|
public static class TransactionFilters
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Categories that represent transfers between accounts, not actual spending.
|
/// Categories that represent transfers between accounts, not actual spending.
|
||||||
/// These should be excluded from spending reports and analytics.
|
/// These should be excluded from spending reports and analytics.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly string[] TransferCategories = new[]
|
public static readonly string[] TransferCategories = new[]
|
||||||
{
|
{
|
||||||
"Credit Card Payment",
|
"Credit Card Payment",
|
||||||
"Bank Transfer",
|
"Bank Transfer",
|
||||||
"Banking" // Includes ATM withdrawals, transfers, fees that offset elsewhere
|
"Banking" // Includes ATM withdrawals, transfers, fees that offset elsewhere
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Filter to exclude transfer transactions from spending queries
|
/// Filter to exclude transfer transactions from spending queries
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static IQueryable<Transaction> ExcludeTransfers(this IQueryable<Transaction> query)
|
public static IQueryable<Transaction> ExcludeTransfers(this IQueryable<Transaction> query)
|
||||||
{
|
{
|
||||||
return query.Where(t => !TransferCategories.Contains(t.Category ?? ""));
|
return query.Where(t => !TransferCategories.Contains(t.Category ?? ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Check if a category represents a transfer (not actual spending)
|
/// Check if a category represents a transfer (not actual spending)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool IsTransferCategory(string? category)
|
public static bool IsTransferCategory(string? category)
|
||||||
{
|
{
|
||||||
return TransferCategories.Contains(category ?? "");
|
return TransferCategories.Contains(category ?? "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,167 +1,167 @@
|
|||||||
using CsvHelper;
|
using CsvHelper;
|
||||||
using CsvHelper.Configuration;
|
using CsvHelper.Configuration;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using MoneyMap.Data;
|
using MoneyMap.Data;
|
||||||
using MoneyMap.Models;
|
using MoneyMap.Models;
|
||||||
using MoneyMap.Models.Import;
|
using MoneyMap.Models.Import;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
|
||||||
namespace MoneyMap.Services
|
namespace MoneyMap.Services
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Service for importing transactions from CSV files.
|
/// Service for importing transactions from CSV files.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface ITransactionImporter
|
public interface ITransactionImporter
|
||||||
{
|
{
|
||||||
Task<PreviewOperationResult> PreviewAsync(Stream csvStream, ImportContext context);
|
Task<PreviewOperationResult> PreviewAsync(Stream csvStream, ImportContext context);
|
||||||
Task<ImportOperationResult> ImportAsync(List<Transaction> transactions);
|
Task<ImportOperationResult> ImportAsync(List<Transaction> transactions);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class TransactionImporter : ITransactionImporter
|
public class TransactionImporter : ITransactionImporter
|
||||||
{
|
{
|
||||||
private readonly MoneyMapContext _db;
|
private readonly MoneyMapContext _db;
|
||||||
private readonly ICardResolver _cardResolver;
|
private readonly ICardResolver _cardResolver;
|
||||||
|
|
||||||
public TransactionImporter(MoneyMapContext db, ICardResolver cardResolver)
|
public TransactionImporter(MoneyMapContext db, ICardResolver cardResolver)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_cardResolver = cardResolver;
|
_cardResolver = cardResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PreviewOperationResult> PreviewAsync(Stream csvStream, ImportContext context)
|
public async Task<PreviewOperationResult> PreviewAsync(Stream csvStream, ImportContext context)
|
||||||
{
|
{
|
||||||
var previewItems = new List<TransactionPreview>();
|
var previewItems = new List<TransactionPreview>();
|
||||||
var addedInThisBatch = new HashSet<TransactionKey>();
|
var addedInThisBatch = new HashSet<TransactionKey>();
|
||||||
|
|
||||||
// First pass: read CSV to get date range and all transactions
|
// First pass: read CSV to get date range and all transactions
|
||||||
var csvTransactions = new List<(TransactionCsvRow Row, Transaction Transaction, TransactionKey Key)>();
|
var csvTransactions = new List<(TransactionCsvRow Row, Transaction Transaction, TransactionKey Key)>();
|
||||||
DateTime? minDate = null;
|
DateTime? minDate = null;
|
||||||
DateTime? maxDate = null;
|
DateTime? maxDate = null;
|
||||||
|
|
||||||
using (var reader = new StreamReader(csvStream))
|
using (var reader = new StreamReader(csvStream))
|
||||||
using (var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
using (var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||||
{
|
{
|
||||||
HasHeaderRecord = true,
|
HasHeaderRecord = true,
|
||||||
HeaderValidated = null,
|
HeaderValidated = null,
|
||||||
MissingFieldFound = null
|
MissingFieldFound = null
|
||||||
}))
|
}))
|
||||||
{
|
{
|
||||||
csv.Read();
|
csv.Read();
|
||||||
csv.ReadHeader();
|
csv.ReadHeader();
|
||||||
var hasCategory = csv.HeaderRecord?.Any(h => h.Equals("Category", StringComparison.OrdinalIgnoreCase)) ?? false;
|
var hasCategory = csv.HeaderRecord?.Any(h => h.Equals("Category", StringComparison.OrdinalIgnoreCase)) ?? false;
|
||||||
csv.Context.RegisterClassMap(new TransactionCsvRowMap(hasCategory));
|
csv.Context.RegisterClassMap(new TransactionCsvRowMap(hasCategory));
|
||||||
|
|
||||||
while (csv.Read())
|
while (csv.Read())
|
||||||
{
|
{
|
||||||
var row = csv.GetRecord<TransactionCsvRow>();
|
var row = csv.GetRecord<TransactionCsvRow>();
|
||||||
|
|
||||||
var paymentResolution = await _cardResolver.ResolvePaymentAsync(row.Memo, context);
|
var paymentResolution = await _cardResolver.ResolvePaymentAsync(row.Memo, context);
|
||||||
if (!paymentResolution.IsSuccess)
|
if (!paymentResolution.IsSuccess)
|
||||||
return PreviewOperationResult.Failure(paymentResolution.ErrorMessage!);
|
return PreviewOperationResult.Failure(paymentResolution.ErrorMessage!);
|
||||||
|
|
||||||
var transaction = MapToTransaction(row, paymentResolution);
|
var transaction = MapToTransaction(row, paymentResolution);
|
||||||
var key = new TransactionKey(transaction);
|
var key = new TransactionKey(transaction);
|
||||||
|
|
||||||
csvTransactions.Add((row, transaction, key));
|
csvTransactions.Add((row, transaction, key));
|
||||||
|
|
||||||
// Track date range
|
// Track date range
|
||||||
if (minDate == null || transaction.Date < minDate) minDate = transaction.Date;
|
if (minDate == null || transaction.Date < minDate) minDate = transaction.Date;
|
||||||
if (maxDate == null || transaction.Date > maxDate) maxDate = transaction.Date;
|
if (maxDate == null || transaction.Date > maxDate) maxDate = transaction.Date;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load existing transactions within the date range for fast duplicate checking
|
// Load existing transactions within the date range for fast duplicate checking
|
||||||
HashSet<TransactionKey> existingTransactions;
|
HashSet<TransactionKey> existingTransactions;
|
||||||
if (minDate.HasValue && maxDate.HasValue)
|
if (minDate.HasValue && maxDate.HasValue)
|
||||||
{
|
{
|
||||||
// Add a buffer of 1 day on each side to catch any edge cases
|
// Add a buffer of 1 day on each side to catch any edge cases
|
||||||
var startDate = minDate.Value.AddDays(-1);
|
var startDate = minDate.Value.AddDays(-1);
|
||||||
var endDate = maxDate.Value.AddDays(1);
|
var endDate = maxDate.Value.AddDays(1);
|
||||||
|
|
||||||
existingTransactions = await _db.Transactions
|
existingTransactions = await _db.Transactions
|
||||||
.Where(t => t.Date >= startDate && t.Date <= endDate)
|
.Where(t => t.Date >= startDate && t.Date <= endDate)
|
||||||
.Select(t => new TransactionKey(t.Date, t.Amount, t.Name, t.Memo, t.AccountId, t.CardId))
|
.Select(t => new TransactionKey(t.Date, t.Amount, t.Name, t.Memo, t.AccountId, t.CardId))
|
||||||
.ToHashSetAsync();
|
.ToHashSetAsync();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
existingTransactions = new HashSet<TransactionKey>();
|
existingTransactions = new HashSet<TransactionKey>();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second pass: check for duplicates and build preview
|
// Second pass: check for duplicates and build preview
|
||||||
foreach (var (row, transaction, key) in csvTransactions)
|
foreach (var (row, transaction, key) in csvTransactions)
|
||||||
{
|
{
|
||||||
// Fast in-memory duplicate checking
|
// Fast in-memory duplicate checking
|
||||||
bool isDuplicate = addedInThisBatch.Contains(key) || existingTransactions.Contains(key);
|
bool isDuplicate = addedInThisBatch.Contains(key) || existingTransactions.Contains(key);
|
||||||
|
|
||||||
previewItems.Add(new TransactionPreview
|
previewItems.Add(new TransactionPreview
|
||||||
{
|
{
|
||||||
Transaction = transaction,
|
Transaction = transaction,
|
||||||
IsDuplicate = isDuplicate,
|
IsDuplicate = isDuplicate,
|
||||||
PaymentMethodLabel = GetPaymentLabel(transaction, context)
|
PaymentMethodLabel = GetPaymentLabel(transaction, context)
|
||||||
});
|
});
|
||||||
|
|
||||||
addedInThisBatch.Add(key);
|
addedInThisBatch.Add(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Order by date descending (newest first)
|
// Order by date descending (newest first)
|
||||||
var orderedPreview = previewItems.OrderByDescending(p => p.Transaction.Date).ToList();
|
var orderedPreview = previewItems.OrderByDescending(p => p.Transaction.Date).ToList();
|
||||||
|
|
||||||
return PreviewOperationResult.Success(orderedPreview);
|
return PreviewOperationResult.Success(orderedPreview);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ImportOperationResult> ImportAsync(List<Transaction> transactions)
|
public async Task<ImportOperationResult> ImportAsync(List<Transaction> transactions)
|
||||||
{
|
{
|
||||||
int inserted = 0;
|
int inserted = 0;
|
||||||
int skipped = 0;
|
int skipped = 0;
|
||||||
|
|
||||||
foreach (var transaction in transactions)
|
foreach (var transaction in transactions)
|
||||||
{
|
{
|
||||||
_db.Transactions.Add(transaction);
|
_db.Transactions.Add(transaction);
|
||||||
inserted++;
|
inserted++;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
var result = new ImportResult(
|
var result = new ImportResult(
|
||||||
transactions.Count,
|
transactions.Count,
|
||||||
inserted,
|
inserted,
|
||||||
skipped,
|
skipped,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
return ImportOperationResult.Success(result);
|
return ImportOperationResult.Success(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Transaction MapToTransaction(TransactionCsvRow row, PaymentResolutionResult paymentResolution)
|
private static Transaction MapToTransaction(TransactionCsvRow row, PaymentResolutionResult paymentResolution)
|
||||||
{
|
{
|
||||||
return new Transaction
|
return new Transaction
|
||||||
{
|
{
|
||||||
Date = row.Date,
|
Date = row.Date,
|
||||||
TransactionType = row.Transaction?.Trim() ?? "",
|
TransactionType = row.Transaction?.Trim() ?? "",
|
||||||
Name = row.Name?.Trim() ?? "",
|
Name = row.Name?.Trim() ?? "",
|
||||||
Memo = row.Memo?.Trim() ?? "",
|
Memo = row.Memo?.Trim() ?? "",
|
||||||
Amount = row.Amount,
|
Amount = row.Amount,
|
||||||
Category = (row.Category ?? "").Trim(),
|
Category = (row.Category ?? "").Trim(),
|
||||||
Last4 = paymentResolution.Last4,
|
Last4 = paymentResolution.Last4,
|
||||||
CardId = paymentResolution.CardId,
|
CardId = paymentResolution.CardId,
|
||||||
AccountId = paymentResolution.AccountId!.Value
|
AccountId = paymentResolution.AccountId!.Value
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetPaymentLabel(Transaction transaction, ImportContext context)
|
private string GetPaymentLabel(Transaction transaction, ImportContext context)
|
||||||
{
|
{
|
||||||
var account = context.AvailableAccounts.FirstOrDefault(a => a.Id == transaction.AccountId);
|
var account = context.AvailableAccounts.FirstOrDefault(a => a.Id == transaction.AccountId);
|
||||||
var accountLabel = account?.DisplayLabel ?? $"Account ···· {transaction.Last4}";
|
var accountLabel = account?.DisplayLabel ?? $"Account ···· {transaction.Last4}";
|
||||||
|
|
||||||
if (transaction.CardId.HasValue)
|
if (transaction.CardId.HasValue)
|
||||||
{
|
{
|
||||||
var card = context.AvailableCards.FirstOrDefault(c => c.Id == transaction.CardId);
|
var card = context.AvailableCards.FirstOrDefault(c => c.Id == transaction.CardId);
|
||||||
var cardLabel = card?.DisplayLabel ?? $"Card ···· {transaction.Last4}";
|
var cardLabel = card?.DisplayLabel ?? $"Card ···· {transaction.Last4}";
|
||||||
return $"{cardLabel} → {accountLabel}";
|
return $"{cardLabel} → {accountLabel}";
|
||||||
}
|
}
|
||||||
|
|
||||||
return accountLabel;
|
return accountLabel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,79 +1,79 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using MoneyMap.Data;
|
using MoneyMap.Data;
|
||||||
using MoneyMap.Models;
|
using MoneyMap.Models;
|
||||||
|
|
||||||
namespace MoneyMap.Services;
|
namespace MoneyMap.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Service for core transaction operations including duplicate detection,
|
/// Service for core transaction operations including duplicate detection,
|
||||||
/// retrieval, and deletion.
|
/// retrieval, and deletion.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface ITransactionService
|
public interface ITransactionService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks if a transaction is a duplicate based on date, amount, name, memo,
|
/// Checks if a transaction is a duplicate based on date, amount, name, memo,
|
||||||
/// account, and card.
|
/// account, and card.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<bool> IsDuplicateAsync(Transaction transaction);
|
Task<bool> IsDuplicateAsync(Transaction transaction);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets a transaction by ID with optional related data.
|
/// Gets a transaction by ID with optional related data.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Transaction?> GetTransactionByIdAsync(long id, bool includeRelated = false);
|
Task<Transaction?> GetTransactionByIdAsync(long id, bool includeRelated = false);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deletes a transaction and all related data (receipts, parse logs, line items).
|
/// Deletes a transaction and all related data (receipts, parse logs, line items).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<bool> DeleteTransactionAsync(long id);
|
Task<bool> DeleteTransactionAsync(long id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class TransactionService : ITransactionService
|
public class TransactionService : ITransactionService
|
||||||
{
|
{
|
||||||
private readonly MoneyMapContext _db;
|
private readonly MoneyMapContext _db;
|
||||||
|
|
||||||
public TransactionService(MoneyMapContext db)
|
public TransactionService(MoneyMapContext db)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> IsDuplicateAsync(Transaction transaction)
|
public async Task<bool> IsDuplicateAsync(Transaction transaction)
|
||||||
{
|
{
|
||||||
return await _db.Transactions.AnyAsync(t =>
|
return await _db.Transactions.AnyAsync(t =>
|
||||||
t.Date == transaction.Date &&
|
t.Date == transaction.Date &&
|
||||||
t.Amount == transaction.Amount &&
|
t.Amount == transaction.Amount &&
|
||||||
t.Name == transaction.Name &&
|
t.Name == transaction.Name &&
|
||||||
t.Memo == transaction.Memo &&
|
t.Memo == transaction.Memo &&
|
||||||
t.AccountId == transaction.AccountId &&
|
t.AccountId == transaction.AccountId &&
|
||||||
t.CardId == transaction.CardId);
|
t.CardId == transaction.CardId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Transaction?> GetTransactionByIdAsync(long id, bool includeRelated = false)
|
public async Task<Transaction?> GetTransactionByIdAsync(long id, bool includeRelated = false)
|
||||||
{
|
{
|
||||||
var query = _db.Transactions.AsQueryable();
|
var query = _db.Transactions.AsQueryable();
|
||||||
|
|
||||||
if (includeRelated)
|
if (includeRelated)
|
||||||
{
|
{
|
||||||
query = query
|
query = query
|
||||||
.Include(t => t.Card)
|
.Include(t => t.Card)
|
||||||
.ThenInclude(c => c!.Account)
|
.ThenInclude(c => c!.Account)
|
||||||
.Include(t => t.Account)
|
.Include(t => t.Account)
|
||||||
.Include(t => t.TransferToAccount)
|
.Include(t => t.TransferToAccount)
|
||||||
.Include(t => t.Merchant)
|
.Include(t => t.Merchant)
|
||||||
.Include(t => t.Receipts)
|
.Include(t => t.Receipts)
|
||||||
.ThenInclude(r => r.LineItems);
|
.ThenInclude(r => r.LineItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await query.FirstOrDefaultAsync(t => t.Id == id);
|
return await query.FirstOrDefaultAsync(t => t.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> DeleteTransactionAsync(long id)
|
public async Task<bool> DeleteTransactionAsync(long id)
|
||||||
{
|
{
|
||||||
var transaction = await _db.Transactions.FindAsync(id);
|
var transaction = await _db.Transactions.FindAsync(id);
|
||||||
if (transaction == null)
|
if (transaction == null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
_db.Transactions.Remove(transaction);
|
_db.Transactions.Remove(transaction);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,105 +1,105 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using MoneyMap.Data;
|
using MoneyMap.Data;
|
||||||
using MoneyMap.Models;
|
using MoneyMap.Models;
|
||||||
|
|
||||||
namespace MoneyMap.Services;
|
namespace MoneyMap.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Service for calculating transaction statistics and aggregates.
|
/// Service for calculating transaction statistics and aggregates.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface ITransactionStatisticsService
|
public interface ITransactionStatisticsService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Calculates statistics for a filtered set of transactions.
|
/// Calculates statistics for a filtered set of transactions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<TransactionStats> CalculateStatsAsync(IQueryable<Transaction> query);
|
Task<TransactionStats> CalculateStatsAsync(IQueryable<Transaction> query);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets categorization statistics for the entire database.
|
/// Gets categorization statistics for the entire database.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<CategorizationStats> GetCategorizationStatsAsync();
|
Task<CategorizationStats> GetCategorizationStatsAsync();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets card statistics for a specific account.
|
/// Gets card statistics for a specific account.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<CardStats>> GetCardStatsForAccountAsync(int accountId);
|
Task<List<CardStats>> GetCardStatsForAccountAsync(int accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class TransactionStatisticsService : ITransactionStatisticsService
|
public class TransactionStatisticsService : ITransactionStatisticsService
|
||||||
{
|
{
|
||||||
private readonly MoneyMapContext _db;
|
private readonly MoneyMapContext _db;
|
||||||
|
|
||||||
public TransactionStatisticsService(MoneyMapContext db)
|
public TransactionStatisticsService(MoneyMapContext db)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<TransactionStats> CalculateStatsAsync(IQueryable<Transaction> query)
|
public async Task<TransactionStats> CalculateStatsAsync(IQueryable<Transaction> query)
|
||||||
{
|
{
|
||||||
// Calculate stats at database level instead of loading all transactions into memory
|
// Calculate stats at database level instead of loading all transactions into memory
|
||||||
var stats = await query
|
var stats = await query
|
||||||
.GroupBy(_ => 1) // Group all into one group to aggregate
|
.GroupBy(_ => 1) // Group all into one group to aggregate
|
||||||
.Select(g => new TransactionStats
|
.Select(g => new TransactionStats
|
||||||
{
|
{
|
||||||
Count = g.Count(),
|
Count = g.Count(),
|
||||||
TotalDebits = g.Where(t => t.Amount < 0).Sum(t => t.Amount),
|
TotalDebits = g.Where(t => t.Amount < 0).Sum(t => t.Amount),
|
||||||
TotalCredits = 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)
|
NetAmount = g.Sum(t => t.Amount)
|
||||||
})
|
})
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
return stats ?? new TransactionStats();
|
return stats ?? new TransactionStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<CategorizationStats> GetCategorizationStatsAsync()
|
public async Task<CategorizationStats> GetCategorizationStatsAsync()
|
||||||
{
|
{
|
||||||
var totalTransactions = await _db.Transactions.CountAsync();
|
var totalTransactions = await _db.Transactions.CountAsync();
|
||||||
var uncategorized = await _db.Transactions
|
var uncategorized = await _db.Transactions
|
||||||
.CountAsync(t => string.IsNullOrWhiteSpace(t.Category));
|
.CountAsync(t => string.IsNullOrWhiteSpace(t.Category));
|
||||||
var categorized = totalTransactions - uncategorized;
|
var categorized = totalTransactions - uncategorized;
|
||||||
|
|
||||||
return new CategorizationStats
|
return new CategorizationStats
|
||||||
{
|
{
|
||||||
TotalTransactions = totalTransactions,
|
TotalTransactions = totalTransactions,
|
||||||
Categorized = categorized,
|
Categorized = categorized,
|
||||||
Uncategorized = uncategorized
|
Uncategorized = uncategorized
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<CardStats>> GetCardStatsForAccountAsync(int accountId)
|
public async Task<List<CardStats>> GetCardStatsForAccountAsync(int accountId)
|
||||||
{
|
{
|
||||||
// Single query with projection to avoid N+1
|
// Single query with projection to avoid N+1
|
||||||
return await _db.Cards
|
return await _db.Cards
|
||||||
.Where(c => c.AccountId == accountId)
|
.Where(c => c.AccountId == accountId)
|
||||||
.OrderBy(c => c.Owner)
|
.OrderBy(c => c.Owner)
|
||||||
.ThenBy(c => c.Last4)
|
.ThenBy(c => c.Last4)
|
||||||
.Select(c => new CardStats
|
.Select(c => new CardStats
|
||||||
{
|
{
|
||||||
Card = c,
|
Card = c,
|
||||||
TransactionCount = c.Transactions.Count
|
TransactionCount = c.Transactions.Count
|
||||||
})
|
})
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DTOs
|
// DTOs
|
||||||
public class TransactionStats
|
public class TransactionStats
|
||||||
{
|
{
|
||||||
public int Count { get; set; }
|
public int Count { get; set; }
|
||||||
public decimal TotalDebits { get; set; }
|
public decimal TotalDebits { get; set; }
|
||||||
public decimal TotalCredits { get; set; }
|
public decimal TotalCredits { get; set; }
|
||||||
public decimal NetAmount { get; set; }
|
public decimal NetAmount { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CategorizationStats
|
public class CategorizationStats
|
||||||
{
|
{
|
||||||
public int TotalTransactions { get; set; }
|
public int TotalTransactions { get; set; }
|
||||||
public int Categorized { get; set; }
|
public int Categorized { get; set; }
|
||||||
public int Uncategorized { get; set; }
|
public int Uncategorized { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CardStats
|
public class CardStats
|
||||||
{
|
{
|
||||||
public Card Card { get; set; } = null!;
|
public Card Card { get; set; } = null!;
|
||||||
public int TransactionCount { get; set; }
|
public int TransactionCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.10" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.10" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
<PackageReference Include="Moq" Version="4.20.72" />
|
<PackageReference Include="Moq" Version="4.20.72" />
|
||||||
<PackageReference Include="xunit" Version="2.9.2" />
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Using Include="Xunit" />
|
<Using Include="Xunit" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\MoneyMap\MoneyMap.csproj" />
|
<ProjectReference Include="..\MoneyMap\MoneyMap.csproj" />
|
||||||
<ProjectReference Include="..\MoneyMap.Core\MoneyMap.Core.csproj" />
|
<ProjectReference Include="..\MoneyMap.Core\MoneyMap.Core.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,236 +1,236 @@
|
|||||||
using MoneyMap.Models;
|
using MoneyMap.Models;
|
||||||
using MoneyMap.Services;
|
using MoneyMap.Services;
|
||||||
using MoneyMap.Tests.TestHelpers;
|
using MoneyMap.Tests.TestHelpers;
|
||||||
|
|
||||||
namespace MoneyMap.Tests.Services;
|
namespace MoneyMap.Tests.Services;
|
||||||
|
|
||||||
public class AccountServiceTests
|
public class AccountServiceTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAllAccountsWithStatsAsync_ReturnsAccountsWithTransactionCounts()
|
public async Task GetAllAccountsWithStatsAsync_ReturnsAccountsWithTransactionCounts()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new AccountService(context);
|
var service = new AccountService(context);
|
||||||
|
|
||||||
var account1 = new Account
|
var account1 = new Account
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Institution = "Bank A",
|
Institution = "Bank A",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "John Doe"
|
Owner = "John Doe"
|
||||||
};
|
};
|
||||||
var account2 = new Account
|
var account2 = new Account
|
||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
Institution = "Bank B",
|
Institution = "Bank B",
|
||||||
AccountType = AccountType.Savings,
|
AccountType = AccountType.Savings,
|
||||||
Last4 = "5678",
|
Last4 = "5678",
|
||||||
Owner = "Jane Smith"
|
Owner = "Jane Smith"
|
||||||
};
|
};
|
||||||
context.Accounts.AddRange(account1, account2);
|
context.Accounts.AddRange(account1, account2);
|
||||||
|
|
||||||
var transaction1 = new Transaction
|
var transaction1 = new Transaction
|
||||||
{
|
{
|
||||||
Date = DateTime.Now,
|
Date = DateTime.Now,
|
||||||
Amount = -50.00m,
|
Amount = -50.00m,
|
||||||
Name = "Test",
|
Name = "Test",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1
|
AccountId = 1
|
||||||
};
|
};
|
||||||
var transaction2 = new Transaction
|
var transaction2 = new Transaction
|
||||||
{
|
{
|
||||||
Date = DateTime.Now,
|
Date = DateTime.Now,
|
||||||
Amount = -25.00m,
|
Amount = -25.00m,
|
||||||
Name = "Test",
|
Name = "Test",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1
|
AccountId = 1
|
||||||
};
|
};
|
||||||
context.Transactions.AddRange(transaction1, transaction2);
|
context.Transactions.AddRange(transaction1, transaction2);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.GetAllAccountsWithStatsAsync();
|
var result = await service.GetAllAccountsWithStatsAsync();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(2, result.Count);
|
Assert.Equal(2, result.Count);
|
||||||
var account1Stats = result.First(a => a.Id == 1);
|
var account1Stats = result.First(a => a.Id == 1);
|
||||||
Assert.Equal(2, account1Stats.TransactionCount);
|
Assert.Equal(2, account1Stats.TransactionCount);
|
||||||
var account2Stats = result.First(a => a.Id == 2);
|
var account2Stats = result.First(a => a.Id == 2);
|
||||||
Assert.Equal(0, account2Stats.TransactionCount);
|
Assert.Equal(0, account2Stats.TransactionCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CanDeleteAccountAsync_ReturnsFalse_WhenAccountHasTransactions()
|
public async Task CanDeleteAccountAsync_ReturnsFalse_WhenAccountHasTransactions()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new AccountService(context);
|
var service = new AccountService(context);
|
||||||
|
|
||||||
var account = new Account
|
var account = new Account
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Institution = "Test Bank",
|
Institution = "Test Bank",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Accounts.Add(account);
|
context.Accounts.Add(account);
|
||||||
|
|
||||||
var transaction = new Transaction
|
var transaction = new Transaction
|
||||||
{
|
{
|
||||||
Date = DateTime.Now,
|
Date = DateTime.Now,
|
||||||
Amount = -50.00m,
|
Amount = -50.00m,
|
||||||
Name = "Test",
|
Name = "Test",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1
|
AccountId = 1
|
||||||
};
|
};
|
||||||
context.Transactions.Add(transaction);
|
context.Transactions.Add(transaction);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.CanDeleteAccountAsync(1);
|
var result = await service.CanDeleteAccountAsync(1);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.False(result.CanDelete);
|
Assert.False(result.CanDelete);
|
||||||
Assert.Contains("transaction", result.Reason, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("transaction", result.Reason, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CanDeleteAccountAsync_ReturnsTrue_WhenAccountHasNoTransactions()
|
public async Task CanDeleteAccountAsync_ReturnsTrue_WhenAccountHasNoTransactions()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new AccountService(context);
|
var service = new AccountService(context);
|
||||||
|
|
||||||
var account = new Account
|
var account = new Account
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Institution = "Test Bank",
|
Institution = "Test Bank",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Accounts.Add(account);
|
context.Accounts.Add(account);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.CanDeleteAccountAsync(1);
|
var result = await service.CanDeleteAccountAsync(1);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.True(result.CanDelete);
|
Assert.True(result.CanDelete);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteAccountAsync_SuccessfullyDeletesAccount_WhenNoTransactions()
|
public async Task DeleteAccountAsync_SuccessfullyDeletesAccount_WhenNoTransactions()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new AccountService(context);
|
var service = new AccountService(context);
|
||||||
|
|
||||||
var account = new Account
|
var account = new Account
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Institution = "Test Bank",
|
Institution = "Test Bank",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Accounts.Add(account);
|
context.Accounts.Add(account);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.DeleteAccountAsync(1);
|
var result = await service.DeleteAccountAsync(1);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.True(result.Success);
|
Assert.True(result.Success);
|
||||||
Assert.Empty(context.Accounts);
|
Assert.Empty(context.Accounts);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteAccountAsync_FailsToDelete_WhenAccountHasTransactions()
|
public async Task DeleteAccountAsync_FailsToDelete_WhenAccountHasTransactions()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new AccountService(context);
|
var service = new AccountService(context);
|
||||||
|
|
||||||
var account = new Account
|
var account = new Account
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Institution = "Test Bank",
|
Institution = "Test Bank",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Accounts.Add(account);
|
context.Accounts.Add(account);
|
||||||
|
|
||||||
var transaction = new Transaction
|
var transaction = new Transaction
|
||||||
{
|
{
|
||||||
Date = DateTime.Now,
|
Date = DateTime.Now,
|
||||||
Amount = -50.00m,
|
Amount = -50.00m,
|
||||||
Name = "Test",
|
Name = "Test",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1
|
AccountId = 1
|
||||||
};
|
};
|
||||||
context.Transactions.Add(transaction);
|
context.Transactions.Add(transaction);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.DeleteAccountAsync(1);
|
var result = await service.DeleteAccountAsync(1);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.False(result.Success);
|
Assert.False(result.Success);
|
||||||
Assert.Single(context.Accounts);
|
Assert.Single(context.Accounts);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAccountDetailsAsync_ReturnsFullDetails_WithCardsAndCounts()
|
public async Task GetAccountDetailsAsync_ReturnsFullDetails_WithCardsAndCounts()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new AccountService(context);
|
var service = new AccountService(context);
|
||||||
|
|
||||||
var account = new Account
|
var account = new Account
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Institution = "Test Bank",
|
Institution = "Test Bank",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Accounts.Add(account);
|
context.Accounts.Add(account);
|
||||||
|
|
||||||
var card = new Card
|
var card = new Card
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
Issuer = "VISA",
|
Issuer = "VISA",
|
||||||
Last4 = "9999",
|
Last4 = "9999",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Cards.Add(card);
|
context.Cards.Add(card);
|
||||||
|
|
||||||
var transaction = new Transaction
|
var transaction = new Transaction
|
||||||
{
|
{
|
||||||
Date = DateTime.Now,
|
Date = DateTime.Now,
|
||||||
Amount = -50.00m,
|
Amount = -50.00m,
|
||||||
Name = "Test",
|
Name = "Test",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
CardId = 1
|
CardId = 1
|
||||||
};
|
};
|
||||||
context.Transactions.Add(transaction);
|
context.Transactions.Add(transaction);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.GetAccountDetailsAsync(1);
|
var result = await service.GetAccountDetailsAsync(1);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.NotNull(result);
|
Assert.NotNull(result);
|
||||||
Assert.Equal(1, result.Account.Id);
|
Assert.Equal(1, result.Account.Id);
|
||||||
Assert.Single(result.Cards);
|
Assert.Single(result.Cards);
|
||||||
Assert.Equal(1, result.Cards[0].TransactionCount);
|
Assert.Equal(1, result.Cards[0].TransactionCount);
|
||||||
Assert.Equal(1, result.TransactionCount);
|
Assert.Equal(1, result.TransactionCount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,231 +1,231 @@
|
|||||||
using MoneyMap.Models;
|
using MoneyMap.Models;
|
||||||
using MoneyMap.Services;
|
using MoneyMap.Services;
|
||||||
using MoneyMap.Tests.TestHelpers;
|
using MoneyMap.Tests.TestHelpers;
|
||||||
|
|
||||||
namespace MoneyMap.Tests.Services;
|
namespace MoneyMap.Tests.Services;
|
||||||
|
|
||||||
public class CardServiceTests
|
public class CardServiceTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAllCardsWithStatsAsync_ReturnsCardsWithTransactionCounts()
|
public async Task GetAllCardsWithStatsAsync_ReturnsCardsWithTransactionCounts()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new CardService(context);
|
var service = new CardService(context);
|
||||||
|
|
||||||
var account = new Account
|
var account = new Account
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Institution = "Test Bank",
|
Institution = "Test Bank",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Accounts.Add(account);
|
context.Accounts.Add(account);
|
||||||
|
|
||||||
var card1 = new Card
|
var card1 = new Card
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
Issuer = "VISA",
|
Issuer = "VISA",
|
||||||
Last4 = "1111",
|
Last4 = "1111",
|
||||||
Owner = "John Doe"
|
Owner = "John Doe"
|
||||||
};
|
};
|
||||||
var card2 = new Card
|
var card2 = new Card
|
||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
Issuer = "Mastercard",
|
Issuer = "Mastercard",
|
||||||
Last4 = "2222",
|
Last4 = "2222",
|
||||||
Owner = "Jane Smith"
|
Owner = "Jane Smith"
|
||||||
};
|
};
|
||||||
context.Cards.AddRange(card1, card2);
|
context.Cards.AddRange(card1, card2);
|
||||||
|
|
||||||
var transaction = new Transaction
|
var transaction = new Transaction
|
||||||
{
|
{
|
||||||
Date = DateTime.Now,
|
Date = DateTime.Now,
|
||||||
Amount = -50.00m,
|
Amount = -50.00m,
|
||||||
Name = "Test",
|
Name = "Test",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
CardId = 1
|
CardId = 1
|
||||||
};
|
};
|
||||||
context.Transactions.Add(transaction);
|
context.Transactions.Add(transaction);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.GetAllCardsWithStatsAsync();
|
var result = await service.GetAllCardsWithStatsAsync();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(2, result.Count);
|
Assert.Equal(2, result.Count);
|
||||||
var card1Stats = result.First(c => c.Card.Id == 1);
|
var card1Stats = result.First(c => c.Card.Id == 1);
|
||||||
Assert.Equal(1, card1Stats.TransactionCount);
|
Assert.Equal(1, card1Stats.TransactionCount);
|
||||||
var card2Stats = result.First(c => c.Card.Id == 2);
|
var card2Stats = result.First(c => c.Card.Id == 2);
|
||||||
Assert.Equal(0, card2Stats.TransactionCount);
|
Assert.Equal(0, card2Stats.TransactionCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CanDeleteCardAsync_ReturnsFalse_WhenCardHasTransactions()
|
public async Task CanDeleteCardAsync_ReturnsFalse_WhenCardHasTransactions()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new CardService(context);
|
var service = new CardService(context);
|
||||||
|
|
||||||
var account = new Account
|
var account = new Account
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Institution = "Test Bank",
|
Institution = "Test Bank",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Accounts.Add(account);
|
context.Accounts.Add(account);
|
||||||
|
|
||||||
var card = new Card
|
var card = new Card
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
Issuer = "VISA",
|
Issuer = "VISA",
|
||||||
Last4 = "9999",
|
Last4 = "9999",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Cards.Add(card);
|
context.Cards.Add(card);
|
||||||
|
|
||||||
var transaction = new Transaction
|
var transaction = new Transaction
|
||||||
{
|
{
|
||||||
Date = DateTime.Now,
|
Date = DateTime.Now,
|
||||||
Amount = -50.00m,
|
Amount = -50.00m,
|
||||||
Name = "Test",
|
Name = "Test",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
CardId = 1
|
CardId = 1
|
||||||
};
|
};
|
||||||
context.Transactions.Add(transaction);
|
context.Transactions.Add(transaction);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.CanDeleteCardAsync(1);
|
var result = await service.CanDeleteCardAsync(1);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.False(result.CanDelete);
|
Assert.False(result.CanDelete);
|
||||||
Assert.Contains("transaction", result.Reason, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("transaction", result.Reason, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CanDeleteCardAsync_ReturnsTrue_WhenCardHasNoTransactions()
|
public async Task CanDeleteCardAsync_ReturnsTrue_WhenCardHasNoTransactions()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new CardService(context);
|
var service = new CardService(context);
|
||||||
|
|
||||||
var account = new Account
|
var account = new Account
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Institution = "Test Bank",
|
Institution = "Test Bank",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Accounts.Add(account);
|
context.Accounts.Add(account);
|
||||||
|
|
||||||
var card = new Card
|
var card = new Card
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
Issuer = "VISA",
|
Issuer = "VISA",
|
||||||
Last4 = "9999",
|
Last4 = "9999",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Cards.Add(card);
|
context.Cards.Add(card);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.CanDeleteCardAsync(1);
|
var result = await service.CanDeleteCardAsync(1);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.True(result.CanDelete);
|
Assert.True(result.CanDelete);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteCardAsync_SuccessfullyDeletesCard_WhenNoTransactions()
|
public async Task DeleteCardAsync_SuccessfullyDeletesCard_WhenNoTransactions()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new CardService(context);
|
var service = new CardService(context);
|
||||||
|
|
||||||
var account = new Account
|
var account = new Account
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Institution = "Test Bank",
|
Institution = "Test Bank",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Accounts.Add(account);
|
context.Accounts.Add(account);
|
||||||
|
|
||||||
var card = new Card
|
var card = new Card
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
Issuer = "VISA",
|
Issuer = "VISA",
|
||||||
Last4 = "9999",
|
Last4 = "9999",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Cards.Add(card);
|
context.Cards.Add(card);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.DeleteCardAsync(1);
|
var result = await service.DeleteCardAsync(1);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.True(result.Success);
|
Assert.True(result.Success);
|
||||||
Assert.Empty(context.Cards);
|
Assert.Empty(context.Cards);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteCardAsync_FailsToDelete_WhenCardHasTransactions()
|
public async Task DeleteCardAsync_FailsToDelete_WhenCardHasTransactions()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new CardService(context);
|
var service = new CardService(context);
|
||||||
|
|
||||||
var account = new Account
|
var account = new Account
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Institution = "Test Bank",
|
Institution = "Test Bank",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Accounts.Add(account);
|
context.Accounts.Add(account);
|
||||||
|
|
||||||
var card = new Card
|
var card = new Card
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
Issuer = "VISA",
|
Issuer = "VISA",
|
||||||
Last4 = "9999",
|
Last4 = "9999",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Cards.Add(card);
|
context.Cards.Add(card);
|
||||||
|
|
||||||
var transaction = new Transaction
|
var transaction = new Transaction
|
||||||
{
|
{
|
||||||
Date = DateTime.Now,
|
Date = DateTime.Now,
|
||||||
Amount = -50.00m,
|
Amount = -50.00m,
|
||||||
Name = "Test",
|
Name = "Test",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
CardId = 1
|
CardId = 1
|
||||||
};
|
};
|
||||||
context.Transactions.Add(transaction);
|
context.Transactions.Add(transaction);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.DeleteCardAsync(1);
|
var result = await service.DeleteCardAsync(1);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.False(result.Success);
|
Assert.False(result.Success);
|
||||||
Assert.Single(context.Cards);
|
Assert.Single(context.Cards);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,188 +1,188 @@
|
|||||||
using MoneyMap.Models;
|
using MoneyMap.Models;
|
||||||
using MoneyMap.Services;
|
using MoneyMap.Services;
|
||||||
using MoneyMap.Tests.TestHelpers;
|
using MoneyMap.Tests.TestHelpers;
|
||||||
|
|
||||||
namespace MoneyMap.Tests.Services;
|
namespace MoneyMap.Tests.Services;
|
||||||
|
|
||||||
public class MerchantServiceTests
|
public class MerchantServiceTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetOrCreateAsync_CreatesNewMerchant_WhenDoesNotExist()
|
public async Task GetOrCreateAsync_CreatesNewMerchant_WhenDoesNotExist()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new MerchantService(context);
|
var service = new MerchantService(context);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.GetOrCreateAsync("Walmart");
|
var result = await service.GetOrCreateAsync("Walmart");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.NotNull(result);
|
Assert.NotNull(result);
|
||||||
Assert.Equal("Walmart", result.Name);
|
Assert.Equal("Walmart", result.Name);
|
||||||
Assert.Single(context.Merchants);
|
Assert.Single(context.Merchants);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetOrCreateAsync_ReturnsExistingMerchant_WhenExists()
|
public async Task GetOrCreateAsync_ReturnsExistingMerchant_WhenExists()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new MerchantService(context);
|
var service = new MerchantService(context);
|
||||||
|
|
||||||
var existingMerchant = new Merchant { Id = 1, Name = "Walmart" };
|
var existingMerchant = new Merchant { Id = 1, Name = "Walmart" };
|
||||||
context.Merchants.Add(existingMerchant);
|
context.Merchants.Add(existingMerchant);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.GetOrCreateAsync("Walmart");
|
var result = await service.GetOrCreateAsync("Walmart");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.NotNull(result);
|
Assert.NotNull(result);
|
||||||
Assert.Equal(1, result.Id);
|
Assert.Equal(1, result.Id);
|
||||||
Assert.Single(context.Merchants);
|
Assert.Single(context.Merchants);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAllMerchantsWithStatsAsync_ReturnsMerchantsWithStats()
|
public async Task GetAllMerchantsWithStatsAsync_ReturnsMerchantsWithStats()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new MerchantService(context);
|
var service = new MerchantService(context);
|
||||||
|
|
||||||
var account = new Account
|
var account = new Account
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Institution = "Test Bank",
|
Institution = "Test Bank",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Accounts.Add(account);
|
context.Accounts.Add(account);
|
||||||
|
|
||||||
var merchant1 = new Merchant { Id = 1, Name = "Walmart" };
|
var merchant1 = new Merchant { Id = 1, Name = "Walmart" };
|
||||||
var merchant2 = new Merchant { Id = 2, Name = "Target" };
|
var merchant2 = new Merchant { Id = 2, Name = "Target" };
|
||||||
context.Merchants.AddRange(merchant1, merchant2);
|
context.Merchants.AddRange(merchant1, merchant2);
|
||||||
|
|
||||||
var transaction = new Transaction
|
var transaction = new Transaction
|
||||||
{
|
{
|
||||||
Date = DateTime.Now,
|
Date = DateTime.Now,
|
||||||
Amount = -50.00m,
|
Amount = -50.00m,
|
||||||
Name = "Test",
|
Name = "Test",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
MerchantId = 1
|
MerchantId = 1
|
||||||
};
|
};
|
||||||
context.Transactions.Add(transaction);
|
context.Transactions.Add(transaction);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.GetAllMerchantsWithStatsAsync();
|
var result = await service.GetAllMerchantsWithStatsAsync();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(2, result.Count);
|
Assert.Equal(2, result.Count);
|
||||||
var walmart = result.First(m => m.Name == "Walmart");
|
var walmart = result.First(m => m.Name == "Walmart");
|
||||||
Assert.Equal(1, walmart.TransactionCount);
|
Assert.Equal(1, walmart.TransactionCount);
|
||||||
var target = result.First(m => m.Name == "Target");
|
var target = result.First(m => m.Name == "Target");
|
||||||
Assert.Equal(0, target.TransactionCount);
|
Assert.Equal(0, target.TransactionCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UpdateMerchantAsync_SuccessfullyUpdatesName()
|
public async Task UpdateMerchantAsync_SuccessfullyUpdatesName()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new MerchantService(context);
|
var service = new MerchantService(context);
|
||||||
|
|
||||||
var merchant = new Merchant { Id = 1, Name = "Walmart" };
|
var merchant = new Merchant { Id = 1, Name = "Walmart" };
|
||||||
context.Merchants.Add(merchant);
|
context.Merchants.Add(merchant);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.UpdateMerchantAsync(1, "Walmart Supercenter");
|
var result = await service.UpdateMerchantAsync(1, "Walmart Supercenter");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.True(result.Success);
|
Assert.True(result.Success);
|
||||||
var updated = await context.Merchants.FindAsync(1);
|
var updated = await context.Merchants.FindAsync(1);
|
||||||
Assert.Equal("Walmart Supercenter", updated!.Name);
|
Assert.Equal("Walmart Supercenter", updated!.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UpdateMerchantAsync_FailsWhenDuplicateName()
|
public async Task UpdateMerchantAsync_FailsWhenDuplicateName()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new MerchantService(context);
|
var service = new MerchantService(context);
|
||||||
|
|
||||||
var merchant1 = new Merchant { Id = 1, Name = "Walmart" };
|
var merchant1 = new Merchant { Id = 1, Name = "Walmart" };
|
||||||
var merchant2 = new Merchant { Id = 2, Name = "Target" };
|
var merchant2 = new Merchant { Id = 2, Name = "Target" };
|
||||||
context.Merchants.AddRange(merchant1, merchant2);
|
context.Merchants.AddRange(merchant1, merchant2);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.UpdateMerchantAsync(1, "Target");
|
var result = await service.UpdateMerchantAsync(1, "Target");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.False(result.Success);
|
Assert.False(result.Success);
|
||||||
Assert.Contains("already exists", result.Message);
|
Assert.Contains("already exists", result.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteMerchantAsync_SuccessfullyDeletes()
|
public async Task DeleteMerchantAsync_SuccessfullyDeletes()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new MerchantService(context);
|
var service = new MerchantService(context);
|
||||||
|
|
||||||
var merchant = new Merchant { Id = 1, Name = "Walmart" };
|
var merchant = new Merchant { Id = 1, Name = "Walmart" };
|
||||||
context.Merchants.Add(merchant);
|
context.Merchants.Add(merchant);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.DeleteMerchantAsync(1);
|
var result = await service.DeleteMerchantAsync(1);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.True(result.Success);
|
Assert.True(result.Success);
|
||||||
Assert.Empty(context.Merchants);
|
Assert.Empty(context.Merchants);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteMerchantAsync_ReportsTransactionAndMappingCounts()
|
public async Task DeleteMerchantAsync_ReportsTransactionAndMappingCounts()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new MerchantService(context);
|
var service = new MerchantService(context);
|
||||||
|
|
||||||
var account = new Account
|
var account = new Account
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Institution = "Test Bank",
|
Institution = "Test Bank",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Accounts.Add(account);
|
context.Accounts.Add(account);
|
||||||
|
|
||||||
var merchant = new Merchant { Id = 1, Name = "Walmart" };
|
var merchant = new Merchant { Id = 1, Name = "Walmart" };
|
||||||
context.Merchants.Add(merchant);
|
context.Merchants.Add(merchant);
|
||||||
|
|
||||||
var transaction = new Transaction
|
var transaction = new Transaction
|
||||||
{
|
{
|
||||||
Date = DateTime.Now,
|
Date = DateTime.Now,
|
||||||
Amount = -50.00m,
|
Amount = -50.00m,
|
||||||
Name = "Test",
|
Name = "Test",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
MerchantId = 1
|
MerchantId = 1
|
||||||
};
|
};
|
||||||
context.Transactions.Add(transaction);
|
context.Transactions.Add(transaction);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.DeleteMerchantAsync(1);
|
var result = await service.DeleteMerchantAsync(1);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.True(result.Success);
|
Assert.True(result.Success);
|
||||||
Assert.Equal(1, result.TransactionCount);
|
Assert.Equal(1, result.TransactionCount);
|
||||||
Assert.Contains("1 transaction", result.Message);
|
Assert.Contains("1 transaction", result.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,329 +1,329 @@
|
|||||||
using MoneyMap.Models;
|
using MoneyMap.Models;
|
||||||
using MoneyMap.Services;
|
using MoneyMap.Services;
|
||||||
using MoneyMap.Tests.TestHelpers;
|
using MoneyMap.Tests.TestHelpers;
|
||||||
|
|
||||||
namespace MoneyMap.Tests.Services;
|
namespace MoneyMap.Tests.Services;
|
||||||
|
|
||||||
public class ReceiptMatchingServiceTests
|
public class ReceiptMatchingServiceTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetTransactionIdsWithReceiptsAsync_ReturnsTransactionIds()
|
public async Task GetTransactionIdsWithReceiptsAsync_ReturnsTransactionIds()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new ReceiptMatchingService(context);
|
var service = new ReceiptMatchingService(context);
|
||||||
|
|
||||||
var account = new Account
|
var account = new Account
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Institution = "Test Bank",
|
Institution = "Test Bank",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Accounts.Add(account);
|
context.Accounts.Add(account);
|
||||||
|
|
||||||
var transaction1 = new Transaction
|
var transaction1 = new Transaction
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Date = DateTime.Now,
|
Date = DateTime.Now,
|
||||||
Amount = -50.00m,
|
Amount = -50.00m,
|
||||||
Name = "Store A",
|
Name = "Store A",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1
|
AccountId = 1
|
||||||
};
|
};
|
||||||
var transaction2 = new Transaction
|
var transaction2 = new Transaction
|
||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
Date = DateTime.Now,
|
Date = DateTime.Now,
|
||||||
Amount = -30.00m,
|
Amount = -30.00m,
|
||||||
Name = "Store B",
|
Name = "Store B",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1
|
AccountId = 1
|
||||||
};
|
};
|
||||||
context.Transactions.AddRange(transaction1, transaction2);
|
context.Transactions.AddRange(transaction1, transaction2);
|
||||||
|
|
||||||
var receipt = new Receipt
|
var receipt = new Receipt
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
TransactionId = 1,
|
TransactionId = 1,
|
||||||
FileName = "receipt.pdf",
|
FileName = "receipt.pdf",
|
||||||
ContentType = "application/pdf",
|
ContentType = "application/pdf",
|
||||||
StoragePath = "/receipts/receipt.pdf",
|
StoragePath = "/receipts/receipt.pdf",
|
||||||
FileSizeBytes = 1024,
|
FileSizeBytes = 1024,
|
||||||
FileHashSha256 = "hash123",
|
FileHashSha256 = "hash123",
|
||||||
UploadedAtUtc = DateTime.UtcNow
|
UploadedAtUtc = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
context.Receipts.Add(receipt);
|
context.Receipts.Add(receipt);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.GetTransactionIdsWithReceiptsAsync();
|
var result = await service.GetTransactionIdsWithReceiptsAsync();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Single(result);
|
Assert.Single(result);
|
||||||
Assert.Contains(1L, result);
|
Assert.Contains(1L, result);
|
||||||
Assert.DoesNotContain(2L, result);
|
Assert.DoesNotContain(2L, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task FindMatchingTransactionsAsync_FiltersByDateRange()
|
public async Task FindMatchingTransactionsAsync_FiltersByDateRange()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new ReceiptMatchingService(context);
|
var service = new ReceiptMatchingService(context);
|
||||||
|
|
||||||
var account = new Account
|
var account = new Account
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Institution = "Test Bank",
|
Institution = "Test Bank",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Accounts.Add(account);
|
context.Accounts.Add(account);
|
||||||
|
|
||||||
var receiptDate = new DateTime(2025, 1, 15);
|
var receiptDate = new DateTime(2025, 1, 15);
|
||||||
|
|
||||||
var withinRange = new Transaction
|
var withinRange = new Transaction
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Date = receiptDate.AddDays(2), // Within +/- 3 days
|
Date = receiptDate.AddDays(2), // Within +/- 3 days
|
||||||
Amount = -50.00m,
|
Amount = -50.00m,
|
||||||
Name = "Store A",
|
Name = "Store A",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1
|
AccountId = 1
|
||||||
};
|
};
|
||||||
var outsideRange = new Transaction
|
var outsideRange = new Transaction
|
||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
Date = receiptDate.AddDays(5), // Outside +/- 3 days
|
Date = receiptDate.AddDays(5), // Outside +/- 3 days
|
||||||
Amount = -50.00m,
|
Amount = -50.00m,
|
||||||
Name = "Store B",
|
Name = "Store B",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1
|
AccountId = 1
|
||||||
};
|
};
|
||||||
context.Transactions.AddRange(withinRange, outsideRange);
|
context.Transactions.AddRange(withinRange, outsideRange);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
var criteria = new ReceiptMatchCriteria
|
var criteria = new ReceiptMatchCriteria
|
||||||
{
|
{
|
||||||
ReceiptDate = receiptDate,
|
ReceiptDate = receiptDate,
|
||||||
ExcludeTransactionIds = new HashSet<long>()
|
ExcludeTransactionIds = new HashSet<long>()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.FindMatchingTransactionsAsync(criteria);
|
var result = await service.FindMatchingTransactionsAsync(criteria);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Single(result);
|
Assert.Single(result);
|
||||||
Assert.Equal(1, result[0].Id);
|
Assert.Equal(1, result[0].Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task FindMatchingTransactionsAsync_FiltersByAmountTolerance()
|
public async Task FindMatchingTransactionsAsync_FiltersByAmountTolerance()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new ReceiptMatchingService(context);
|
var service = new ReceiptMatchingService(context);
|
||||||
|
|
||||||
var account = new Account
|
var account = new Account
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Institution = "Test Bank",
|
Institution = "Test Bank",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Accounts.Add(account);
|
context.Accounts.Add(account);
|
||||||
|
|
||||||
var receiptDate = DateTime.Now;
|
var receiptDate = DateTime.Now;
|
||||||
var receiptTotal = 100.00m;
|
var receiptTotal = 100.00m;
|
||||||
|
|
||||||
var withinTolerance = new Transaction
|
var withinTolerance = new Transaction
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Date = receiptDate,
|
Date = receiptDate,
|
||||||
Amount = -105.00m, // Within 10% tolerance
|
Amount = -105.00m, // Within 10% tolerance
|
||||||
Name = "Store A",
|
Name = "Store A",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1
|
AccountId = 1
|
||||||
};
|
};
|
||||||
var outsideTolerance = new Transaction
|
var outsideTolerance = new Transaction
|
||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
Date = receiptDate,
|
Date = receiptDate,
|
||||||
Amount = -150.00m, // Outside 10% tolerance
|
Amount = -150.00m, // Outside 10% tolerance
|
||||||
Name = "Store B",
|
Name = "Store B",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1
|
AccountId = 1
|
||||||
};
|
};
|
||||||
context.Transactions.AddRange(withinTolerance, outsideTolerance);
|
context.Transactions.AddRange(withinTolerance, outsideTolerance);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
var criteria = new ReceiptMatchCriteria
|
var criteria = new ReceiptMatchCriteria
|
||||||
{
|
{
|
||||||
ReceiptDate = receiptDate,
|
ReceiptDate = receiptDate,
|
||||||
Total = receiptTotal,
|
Total = receiptTotal,
|
||||||
ExcludeTransactionIds = new HashSet<long>()
|
ExcludeTransactionIds = new HashSet<long>()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.FindMatchingTransactionsAsync(criteria);
|
var result = await service.FindMatchingTransactionsAsync(criteria);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Single(result);
|
Assert.Single(result);
|
||||||
Assert.Equal(1, result[0].Id);
|
Assert.Equal(1, result[0].Id);
|
||||||
Assert.True(result[0].IsCloseAmount);
|
Assert.True(result[0].IsCloseAmount);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task FindMatchingTransactionsAsync_MarksExactAmountMatch()
|
public async Task FindMatchingTransactionsAsync_MarksExactAmountMatch()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new ReceiptMatchingService(context);
|
var service = new ReceiptMatchingService(context);
|
||||||
|
|
||||||
var account = new Account
|
var account = new Account
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Institution = "Test Bank",
|
Institution = "Test Bank",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Accounts.Add(account);
|
context.Accounts.Add(account);
|
||||||
|
|
||||||
var receiptDate = DateTime.Now;
|
var receiptDate = DateTime.Now;
|
||||||
var receiptTotal = 100.00m;
|
var receiptTotal = 100.00m;
|
||||||
|
|
||||||
var exactMatch = new Transaction
|
var exactMatch = new Transaction
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Date = receiptDate,
|
Date = receiptDate,
|
||||||
Amount = -100.00m, // Exact match
|
Amount = -100.00m, // Exact match
|
||||||
Name = "Store A",
|
Name = "Store A",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1
|
AccountId = 1
|
||||||
};
|
};
|
||||||
context.Transactions.Add(exactMatch);
|
context.Transactions.Add(exactMatch);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
var criteria = new ReceiptMatchCriteria
|
var criteria = new ReceiptMatchCriteria
|
||||||
{
|
{
|
||||||
ReceiptDate = receiptDate,
|
ReceiptDate = receiptDate,
|
||||||
Total = receiptTotal,
|
Total = receiptTotal,
|
||||||
ExcludeTransactionIds = new HashSet<long>()
|
ExcludeTransactionIds = new HashSet<long>()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.FindMatchingTransactionsAsync(criteria);
|
var result = await service.FindMatchingTransactionsAsync(criteria);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Single(result);
|
Assert.Single(result);
|
||||||
Assert.True(result[0].IsExactAmount);
|
Assert.True(result[0].IsExactAmount);
|
||||||
Assert.False(result[0].IsCloseAmount);
|
Assert.False(result[0].IsCloseAmount);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task FindMatchingTransactionsAsync_ExcludesTransactionsWithReceipts()
|
public async Task FindMatchingTransactionsAsync_ExcludesTransactionsWithReceipts()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new ReceiptMatchingService(context);
|
var service = new ReceiptMatchingService(context);
|
||||||
|
|
||||||
var account = new Account
|
var account = new Account
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Institution = "Test Bank",
|
Institution = "Test Bank",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Accounts.Add(account);
|
context.Accounts.Add(account);
|
||||||
|
|
||||||
var receiptDate = DateTime.Now;
|
var receiptDate = DateTime.Now;
|
||||||
|
|
||||||
var transaction1 = new Transaction
|
var transaction1 = new Transaction
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Date = receiptDate,
|
Date = receiptDate,
|
||||||
Amount = -50.00m,
|
Amount = -50.00m,
|
||||||
Name = "Store A",
|
Name = "Store A",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1
|
AccountId = 1
|
||||||
};
|
};
|
||||||
var transaction2 = new Transaction
|
var transaction2 = new Transaction
|
||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
Date = receiptDate,
|
Date = receiptDate,
|
||||||
Amount = -50.00m,
|
Amount = -50.00m,
|
||||||
Name = "Store B",
|
Name = "Store B",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1
|
AccountId = 1
|
||||||
};
|
};
|
||||||
context.Transactions.AddRange(transaction1, transaction2);
|
context.Transactions.AddRange(transaction1, transaction2);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
var criteria = new ReceiptMatchCriteria
|
var criteria = new ReceiptMatchCriteria
|
||||||
{
|
{
|
||||||
ReceiptDate = receiptDate,
|
ReceiptDate = receiptDate,
|
||||||
ExcludeTransactionIds = new HashSet<long> { 1 } // Exclude transaction 1
|
ExcludeTransactionIds = new HashSet<long> { 1 } // Exclude transaction 1
|
||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.FindMatchingTransactionsAsync(criteria);
|
var result = await service.FindMatchingTransactionsAsync(criteria);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Single(result);
|
Assert.Single(result);
|
||||||
Assert.Equal(2, result[0].Id);
|
Assert.Equal(2, result[0].Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task FindMatchingTransactionsAsync_UsesDueDateForBills()
|
public async Task FindMatchingTransactionsAsync_UsesDueDateForBills()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new ReceiptMatchingService(context);
|
var service = new ReceiptMatchingService(context);
|
||||||
|
|
||||||
var account = new Account
|
var account = new Account
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Institution = "Test Bank",
|
Institution = "Test Bank",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Accounts.Add(account);
|
context.Accounts.Add(account);
|
||||||
|
|
||||||
var receiptDate = new DateTime(2025, 1, 1);
|
var receiptDate = new DateTime(2025, 1, 1);
|
||||||
var dueDate = new DateTime(2025, 1, 15);
|
var dueDate = new DateTime(2025, 1, 15);
|
||||||
|
|
||||||
// Transaction on due date + 3 days (should match for bills)
|
// Transaction on due date + 3 days (should match for bills)
|
||||||
var transaction = new Transaction
|
var transaction = new Transaction
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Date = dueDate.AddDays(3),
|
Date = dueDate.AddDays(3),
|
||||||
Amount = -50.00m,
|
Amount = -50.00m,
|
||||||
Name = "Utility Company",
|
Name = "Utility Company",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1
|
AccountId = 1
|
||||||
};
|
};
|
||||||
context.Transactions.Add(transaction);
|
context.Transactions.Add(transaction);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
var criteria = new ReceiptMatchCriteria
|
var criteria = new ReceiptMatchCriteria
|
||||||
{
|
{
|
||||||
ReceiptDate = receiptDate,
|
ReceiptDate = receiptDate,
|
||||||
DueDate = dueDate, // Bill with due date
|
DueDate = dueDate, // Bill with due date
|
||||||
ExcludeTransactionIds = new HashSet<long>()
|
ExcludeTransactionIds = new HashSet<long>()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.FindMatchingTransactionsAsync(criteria);
|
var result = await service.FindMatchingTransactionsAsync(criteria);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Single(result);
|
Assert.Single(result);
|
||||||
Assert.Equal(1, result[0].Id);
|
Assert.Equal(1, result[0].Id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,220 +1,220 @@
|
|||||||
using MoneyMap.Models;
|
using MoneyMap.Models;
|
||||||
using MoneyMap.Services;
|
using MoneyMap.Services;
|
||||||
using MoneyMap.Tests.TestHelpers;
|
using MoneyMap.Tests.TestHelpers;
|
||||||
|
|
||||||
namespace MoneyMap.Tests.Services;
|
namespace MoneyMap.Tests.Services;
|
||||||
|
|
||||||
public class ReferenceDataServiceTests
|
public class ReferenceDataServiceTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAvailableCategoriesAsync_ReturnsDistinctSortedCategories()
|
public async Task GetAvailableCategoriesAsync_ReturnsDistinctSortedCategories()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new ReferenceDataService(context);
|
var service = new ReferenceDataService(context);
|
||||||
|
|
||||||
var account = new Account
|
var account = new Account
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Institution = "Test Bank",
|
Institution = "Test Bank",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Accounts.Add(account);
|
context.Accounts.Add(account);
|
||||||
|
|
||||||
var transaction1 = new Transaction
|
var transaction1 = new Transaction
|
||||||
{
|
{
|
||||||
Date = DateTime.Now,
|
Date = DateTime.Now,
|
||||||
Amount = -50.00m,
|
Amount = -50.00m,
|
||||||
Name = "Test",
|
Name = "Test",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
Category = "Groceries"
|
Category = "Groceries"
|
||||||
};
|
};
|
||||||
var transaction2 = new Transaction
|
var transaction2 = new Transaction
|
||||||
{
|
{
|
||||||
Date = DateTime.Now,
|
Date = DateTime.Now,
|
||||||
Amount = -30.00m,
|
Amount = -30.00m,
|
||||||
Name = "Test",
|
Name = "Test",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
Category = "Gas"
|
Category = "Gas"
|
||||||
};
|
};
|
||||||
var transaction3 = new Transaction
|
var transaction3 = new Transaction
|
||||||
{
|
{
|
||||||
Date = DateTime.Now,
|
Date = DateTime.Now,
|
||||||
Amount = -20.00m,
|
Amount = -20.00m,
|
||||||
Name = "Test",
|
Name = "Test",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
Category = "Groceries" // Duplicate
|
Category = "Groceries" // Duplicate
|
||||||
};
|
};
|
||||||
context.Transactions.AddRange(transaction1, transaction2, transaction3);
|
context.Transactions.AddRange(transaction1, transaction2, transaction3);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.GetAvailableCategoriesAsync();
|
var result = await service.GetAvailableCategoriesAsync();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(2, result.Count);
|
Assert.Equal(2, result.Count);
|
||||||
Assert.Contains("Groceries", result);
|
Assert.Contains("Groceries", result);
|
||||||
Assert.Contains("Gas", result);
|
Assert.Contains("Gas", result);
|
||||||
Assert.Equal("Gas", result[0]); // Alphabetically sorted
|
Assert.Equal("Gas", result[0]); // Alphabetically sorted
|
||||||
Assert.Equal("Groceries", result[1]);
|
Assert.Equal("Groceries", result[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAvailableCategoriesAsync_ExcludesEmptyCategories()
|
public async Task GetAvailableCategoriesAsync_ExcludesEmptyCategories()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new ReferenceDataService(context);
|
var service = new ReferenceDataService(context);
|
||||||
|
|
||||||
var account = new Account
|
var account = new Account
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Institution = "Test Bank",
|
Institution = "Test Bank",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Accounts.Add(account);
|
context.Accounts.Add(account);
|
||||||
|
|
||||||
var transaction1 = new Transaction
|
var transaction1 = new Transaction
|
||||||
{
|
{
|
||||||
Date = DateTime.Now,
|
Date = DateTime.Now,
|
||||||
Amount = -50.00m,
|
Amount = -50.00m,
|
||||||
Name = "Test",
|
Name = "Test",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
Category = "Groceries"
|
Category = "Groceries"
|
||||||
};
|
};
|
||||||
var transaction2 = new Transaction
|
var transaction2 = new Transaction
|
||||||
{
|
{
|
||||||
Date = DateTime.Now,
|
Date = DateTime.Now,
|
||||||
Amount = -30.00m,
|
Amount = -30.00m,
|
||||||
Name = "Test",
|
Name = "Test",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
Category = "" // Empty
|
Category = "" // Empty
|
||||||
};
|
};
|
||||||
var transaction3 = new Transaction
|
var transaction3 = new Transaction
|
||||||
{
|
{
|
||||||
Date = DateTime.Now,
|
Date = DateTime.Now,
|
||||||
Amount = -20.00m,
|
Amount = -20.00m,
|
||||||
Name = "Test",
|
Name = "Test",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
Category = " " // Whitespace
|
Category = " " // Whitespace
|
||||||
};
|
};
|
||||||
context.Transactions.AddRange(transaction1, transaction2, transaction3);
|
context.Transactions.AddRange(transaction1, transaction2, transaction3);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.GetAvailableCategoriesAsync();
|
var result = await service.GetAvailableCategoriesAsync();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Single(result);
|
Assert.Single(result);
|
||||||
Assert.Equal("Groceries", result[0]);
|
Assert.Equal("Groceries", result[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAvailableMerchantsAsync_ReturnsSortedMerchants()
|
public async Task GetAvailableMerchantsAsync_ReturnsSortedMerchants()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new ReferenceDataService(context);
|
var service = new ReferenceDataService(context);
|
||||||
|
|
||||||
var merchant1 = new Merchant { Name = "Walmart" };
|
var merchant1 = new Merchant { Name = "Walmart" };
|
||||||
var merchant2 = new Merchant { Name = "Amazon" };
|
var merchant2 = new Merchant { Name = "Amazon" };
|
||||||
var merchant3 = new Merchant { Name = "Target" };
|
var merchant3 = new Merchant { Name = "Target" };
|
||||||
context.Merchants.AddRange(merchant1, merchant2, merchant3);
|
context.Merchants.AddRange(merchant1, merchant2, merchant3);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.GetAvailableMerchantsAsync();
|
var result = await service.GetAvailableMerchantsAsync();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(3, result.Count);
|
Assert.Equal(3, result.Count);
|
||||||
Assert.Equal("Amazon", result[0].Name); // Alphabetically sorted
|
Assert.Equal("Amazon", result[0].Name); // Alphabetically sorted
|
||||||
Assert.Equal("Target", result[1].Name);
|
Assert.Equal("Target", result[1].Name);
|
||||||
Assert.Equal("Walmart", result[2].Name);
|
Assert.Equal("Walmart", result[2].Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAvailableCardsAsync_ReturnsSortedCards()
|
public async Task GetAvailableCardsAsync_ReturnsSortedCards()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new ReferenceDataService(context);
|
var service = new ReferenceDataService(context);
|
||||||
|
|
||||||
var account = new Account
|
var account = new Account
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Institution = "Test Bank",
|
Institution = "Test Bank",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Accounts.Add(account);
|
context.Accounts.Add(account);
|
||||||
|
|
||||||
var card1 = new Card
|
var card1 = new Card
|
||||||
{
|
{
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
Issuer = "VISA",
|
Issuer = "VISA",
|
||||||
Last4 = "3333",
|
Last4 = "3333",
|
||||||
Owner = "Bob"
|
Owner = "Bob"
|
||||||
};
|
};
|
||||||
var card2 = new Card
|
var card2 = new Card
|
||||||
{
|
{
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
Issuer = "Mastercard",
|
Issuer = "Mastercard",
|
||||||
Last4 = "1111",
|
Last4 = "1111",
|
||||||
Owner = "Alice"
|
Owner = "Alice"
|
||||||
};
|
};
|
||||||
context.Cards.AddRange(card1, card2);
|
context.Cards.AddRange(card1, card2);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.GetAvailableCardsAsync();
|
var result = await service.GetAvailableCardsAsync();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(2, result.Count);
|
Assert.Equal(2, result.Count);
|
||||||
Assert.Equal("Alice", result[0].Owner); // Sorted by owner
|
Assert.Equal("Alice", result[0].Owner); // Sorted by owner
|
||||||
Assert.Equal("Bob", result[1].Owner);
|
Assert.Equal("Bob", result[1].Owner);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAvailableAccountsAsync_ReturnsSortedAccounts()
|
public async Task GetAvailableAccountsAsync_ReturnsSortedAccounts()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new ReferenceDataService(context);
|
var service = new ReferenceDataService(context);
|
||||||
|
|
||||||
var account1 = new Account
|
var account1 = new Account
|
||||||
{
|
{
|
||||||
Institution = "Wells Fargo",
|
Institution = "Wells Fargo",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "5678",
|
Last4 = "5678",
|
||||||
Owner = "Test"
|
Owner = "Test"
|
||||||
};
|
};
|
||||||
var account2 = new Account
|
var account2 = new Account
|
||||||
{
|
{
|
||||||
Institution = "Bank of America",
|
Institution = "Bank of America",
|
||||||
AccountType = AccountType.Savings,
|
AccountType = AccountType.Savings,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "Test"
|
Owner = "Test"
|
||||||
};
|
};
|
||||||
context.Accounts.AddRange(account1, account2);
|
context.Accounts.AddRange(account1, account2);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.GetAvailableAccountsAsync();
|
var result = await service.GetAvailableAccountsAsync();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(2, result.Count);
|
Assert.Equal(2, result.Count);
|
||||||
Assert.Equal("Bank of America", result[0].Institution); // Sorted by institution
|
Assert.Equal("Bank of America", result[0].Institution); // Sorted by institution
|
||||||
Assert.Equal("Wells Fargo", result[1].Institution);
|
Assert.Equal("Wells Fargo", result[1].Institution);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,228 +1,228 @@
|
|||||||
using MoneyMap.Models;
|
using MoneyMap.Models;
|
||||||
using MoneyMap.Services;
|
using MoneyMap.Services;
|
||||||
using MoneyMap.Tests.TestHelpers;
|
using MoneyMap.Tests.TestHelpers;
|
||||||
|
|
||||||
namespace MoneyMap.Tests.Services;
|
namespace MoneyMap.Tests.Services;
|
||||||
|
|
||||||
public class TransactionServiceTests
|
public class TransactionServiceTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task IsDuplicateAsync_ReturnsFalse_WhenTransactionDoesNotExist()
|
public async Task IsDuplicateAsync_ReturnsFalse_WhenTransactionDoesNotExist()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionService(context);
|
||||||
|
|
||||||
var transaction = new Transaction
|
var transaction = new Transaction
|
||||||
{
|
{
|
||||||
Date = DateTime.Now,
|
Date = DateTime.Now,
|
||||||
Amount = -50.00m,
|
Amount = -50.00m,
|
||||||
Name = "Test Store",
|
Name = "Test Store",
|
||||||
Memo = "Test purchase",
|
Memo = "Test purchase",
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
CardId = 1
|
CardId = 1
|
||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.IsDuplicateAsync(transaction);
|
var result = await service.IsDuplicateAsync(transaction);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.False(result);
|
Assert.False(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task IsDuplicateAsync_ReturnsTrue_WhenExactDuplicateExists()
|
public async Task IsDuplicateAsync_ReturnsTrue_WhenExactDuplicateExists()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionService(context);
|
||||||
|
|
||||||
// Add an account first (required for transaction)
|
// Add an account first (required for transaction)
|
||||||
var account = new Account
|
var account = new Account
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Institution = "Test Bank",
|
Institution = "Test Bank",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Accounts.Add(account);
|
context.Accounts.Add(account);
|
||||||
|
|
||||||
var existingTransaction = new Transaction
|
var existingTransaction = new Transaction
|
||||||
{
|
{
|
||||||
Date = new DateTime(2025, 1, 15),
|
Date = new DateTime(2025, 1, 15),
|
||||||
Amount = -50.00m,
|
Amount = -50.00m,
|
||||||
Name = "Test Store",
|
Name = "Test Store",
|
||||||
Memo = "Test purchase",
|
Memo = "Test purchase",
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
CardId = 1
|
CardId = 1
|
||||||
};
|
};
|
||||||
context.Transactions.Add(existingTransaction);
|
context.Transactions.Add(existingTransaction);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
var duplicateTransaction = new Transaction
|
var duplicateTransaction = new Transaction
|
||||||
{
|
{
|
||||||
Date = new DateTime(2025, 1, 15),
|
Date = new DateTime(2025, 1, 15),
|
||||||
Amount = -50.00m,
|
Amount = -50.00m,
|
||||||
Name = "Test Store",
|
Name = "Test Store",
|
||||||
Memo = "Test purchase",
|
Memo = "Test purchase",
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
CardId = 1
|
CardId = 1
|
||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.IsDuplicateAsync(duplicateTransaction);
|
var result = await service.IsDuplicateAsync(duplicateTransaction);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.True(result);
|
Assert.True(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task IsDuplicateAsync_ReturnsFalse_WhenAmountDiffers()
|
public async Task IsDuplicateAsync_ReturnsFalse_WhenAmountDiffers()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionService(context);
|
||||||
|
|
||||||
var account = new Account
|
var account = new Account
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Institution = "Test Bank",
|
Institution = "Test Bank",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Accounts.Add(account);
|
context.Accounts.Add(account);
|
||||||
|
|
||||||
var existingTransaction = new Transaction
|
var existingTransaction = new Transaction
|
||||||
{
|
{
|
||||||
Date = new DateTime(2025, 1, 15),
|
Date = new DateTime(2025, 1, 15),
|
||||||
Amount = -50.00m,
|
Amount = -50.00m,
|
||||||
Name = "Test Store",
|
Name = "Test Store",
|
||||||
Memo = "Test purchase",
|
Memo = "Test purchase",
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
CardId = 1
|
CardId = 1
|
||||||
};
|
};
|
||||||
context.Transactions.Add(existingTransaction);
|
context.Transactions.Add(existingTransaction);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
var differentTransaction = new Transaction
|
var differentTransaction = new Transaction
|
||||||
{
|
{
|
||||||
Date = new DateTime(2025, 1, 15),
|
Date = new DateTime(2025, 1, 15),
|
||||||
Amount = -51.00m, // Different amount
|
Amount = -51.00m, // Different amount
|
||||||
Name = "Test Store",
|
Name = "Test Store",
|
||||||
Memo = "Test purchase",
|
Memo = "Test purchase",
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
CardId = 1
|
CardId = 1
|
||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.IsDuplicateAsync(differentTransaction);
|
var result = await service.IsDuplicateAsync(differentTransaction);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.False(result);
|
Assert.False(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetTransactionByIdAsync_ReturnsNull_WhenTransactionDoesNotExist()
|
public async Task GetTransactionByIdAsync_ReturnsNull_WhenTransactionDoesNotExist()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionService(context);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.GetTransactionByIdAsync(999);
|
var result = await service.GetTransactionByIdAsync(999);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Null(result);
|
Assert.Null(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetTransactionByIdAsync_ReturnsTransaction_WhenExists()
|
public async Task GetTransactionByIdAsync_ReturnsTransaction_WhenExists()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionService(context);
|
||||||
|
|
||||||
var account = new Account
|
var account = new Account
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Institution = "Test Bank",
|
Institution = "Test Bank",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Accounts.Add(account);
|
context.Accounts.Add(account);
|
||||||
|
|
||||||
var transaction = new Transaction
|
var transaction = new Transaction
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Date = DateTime.Now,
|
Date = DateTime.Now,
|
||||||
Amount = -50.00m,
|
Amount = -50.00m,
|
||||||
Name = "Test Store",
|
Name = "Test Store",
|
||||||
Memo = "Test purchase",
|
Memo = "Test purchase",
|
||||||
AccountId = 1
|
AccountId = 1
|
||||||
};
|
};
|
||||||
context.Transactions.Add(transaction);
|
context.Transactions.Add(transaction);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.GetTransactionByIdAsync(1);
|
var result = await service.GetTransactionByIdAsync(1);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.NotNull(result);
|
Assert.NotNull(result);
|
||||||
Assert.Equal("Test Store", result.Name);
|
Assert.Equal("Test Store", result.Name);
|
||||||
Assert.Equal(-50.00m, result.Amount);
|
Assert.Equal(-50.00m, result.Amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteTransactionAsync_ReturnsTrue_WhenTransactionExists()
|
public async Task DeleteTransactionAsync_ReturnsTrue_WhenTransactionExists()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionService(context);
|
||||||
|
|
||||||
var account = new Account
|
var account = new Account
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Institution = "Test Bank",
|
Institution = "Test Bank",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Accounts.Add(account);
|
context.Accounts.Add(account);
|
||||||
|
|
||||||
var transaction = new Transaction
|
var transaction = new Transaction
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Date = DateTime.Now,
|
Date = DateTime.Now,
|
||||||
Amount = -50.00m,
|
Amount = -50.00m,
|
||||||
Name = "Test Store",
|
Name = "Test Store",
|
||||||
Memo = "Test purchase",
|
Memo = "Test purchase",
|
||||||
AccountId = 1
|
AccountId = 1
|
||||||
};
|
};
|
||||||
context.Transactions.Add(transaction);
|
context.Transactions.Add(transaction);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.DeleteTransactionAsync(1);
|
var result = await service.DeleteTransactionAsync(1);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.True(result);
|
Assert.True(result);
|
||||||
Assert.Empty(context.Transactions);
|
Assert.Empty(context.Transactions);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteTransactionAsync_ReturnsFalse_WhenTransactionDoesNotExist()
|
public async Task DeleteTransactionAsync_ReturnsFalse_WhenTransactionDoesNotExist()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionService(context);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.DeleteTransactionAsync(999);
|
var result = await service.DeleteTransactionAsync(999);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.False(result);
|
Assert.False(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,187 +1,187 @@
|
|||||||
using MoneyMap.Models;
|
using MoneyMap.Models;
|
||||||
using MoneyMap.Services;
|
using MoneyMap.Services;
|
||||||
using MoneyMap.Tests.TestHelpers;
|
using MoneyMap.Tests.TestHelpers;
|
||||||
|
|
||||||
namespace MoneyMap.Tests.Services;
|
namespace MoneyMap.Tests.Services;
|
||||||
|
|
||||||
public class TransactionStatisticsServiceTests
|
public class TransactionStatisticsServiceTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CalculateStatsAsync_ReturnsCorrectStatistics()
|
public async Task CalculateStatsAsync_ReturnsCorrectStatistics()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new TransactionStatisticsService(context);
|
var service = new TransactionStatisticsService(context);
|
||||||
|
|
||||||
var account = new Account
|
var account = new Account
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Institution = "Test Bank",
|
Institution = "Test Bank",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Accounts.Add(account);
|
context.Accounts.Add(account);
|
||||||
|
|
||||||
var debit1 = new Transaction
|
var debit1 = new Transaction
|
||||||
{
|
{
|
||||||
Date = DateTime.Now,
|
Date = DateTime.Now,
|
||||||
Amount = -50.00m,
|
Amount = -50.00m,
|
||||||
Name = "Store A",
|
Name = "Store A",
|
||||||
Memo = "Purchase",
|
Memo = "Purchase",
|
||||||
AccountId = 1
|
AccountId = 1
|
||||||
};
|
};
|
||||||
var debit2 = new Transaction
|
var debit2 = new Transaction
|
||||||
{
|
{
|
||||||
Date = DateTime.Now,
|
Date = DateTime.Now,
|
||||||
Amount = -30.00m,
|
Amount = -30.00m,
|
||||||
Name = "Store B",
|
Name = "Store B",
|
||||||
Memo = "Purchase",
|
Memo = "Purchase",
|
||||||
AccountId = 1
|
AccountId = 1
|
||||||
};
|
};
|
||||||
var credit = new Transaction
|
var credit = new Transaction
|
||||||
{
|
{
|
||||||
Date = DateTime.Now,
|
Date = DateTime.Now,
|
||||||
Amount = 100.00m,
|
Amount = 100.00m,
|
||||||
Name = "Deposit",
|
Name = "Deposit",
|
||||||
Memo = "Paycheck",
|
Memo = "Paycheck",
|
||||||
AccountId = 1
|
AccountId = 1
|
||||||
};
|
};
|
||||||
context.Transactions.AddRange(debit1, debit2, credit);
|
context.Transactions.AddRange(debit1, debit2, credit);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
var query = context.Transactions.AsQueryable();
|
var query = context.Transactions.AsQueryable();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.CalculateStatsAsync(query);
|
var result = await service.CalculateStatsAsync(query);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(3, result.Count);
|
Assert.Equal(3, result.Count);
|
||||||
Assert.Equal(-80.00m, result.TotalDebits);
|
Assert.Equal(-80.00m, result.TotalDebits);
|
||||||
Assert.Equal(100.00m, result.TotalCredits);
|
Assert.Equal(100.00m, result.TotalCredits);
|
||||||
Assert.Equal(20.00m, result.NetAmount);
|
Assert.Equal(20.00m, result.NetAmount);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetCategorizationStatsAsync_ReturnsCorrectCounts()
|
public async Task GetCategorizationStatsAsync_ReturnsCorrectCounts()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new TransactionStatisticsService(context);
|
var service = new TransactionStatisticsService(context);
|
||||||
|
|
||||||
var account = new Account
|
var account = new Account
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Institution = "Test Bank",
|
Institution = "Test Bank",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Accounts.Add(account);
|
context.Accounts.Add(account);
|
||||||
|
|
||||||
var categorized1 = new Transaction
|
var categorized1 = new Transaction
|
||||||
{
|
{
|
||||||
Date = DateTime.Now,
|
Date = DateTime.Now,
|
||||||
Amount = -50.00m,
|
Amount = -50.00m,
|
||||||
Name = "Test",
|
Name = "Test",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
Category = "Groceries"
|
Category = "Groceries"
|
||||||
};
|
};
|
||||||
var categorized2 = new Transaction
|
var categorized2 = new Transaction
|
||||||
{
|
{
|
||||||
Date = DateTime.Now,
|
Date = DateTime.Now,
|
||||||
Amount = -30.00m,
|
Amount = -30.00m,
|
||||||
Name = "Test",
|
Name = "Test",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
Category = "Gas"
|
Category = "Gas"
|
||||||
};
|
};
|
||||||
var uncategorized = new Transaction
|
var uncategorized = new Transaction
|
||||||
{
|
{
|
||||||
Date = DateTime.Now,
|
Date = DateTime.Now,
|
||||||
Amount = -20.00m,
|
Amount = -20.00m,
|
||||||
Name = "Test",
|
Name = "Test",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
Category = ""
|
Category = ""
|
||||||
};
|
};
|
||||||
context.Transactions.AddRange(categorized1, categorized2, uncategorized);
|
context.Transactions.AddRange(categorized1, categorized2, uncategorized);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.GetCategorizationStatsAsync();
|
var result = await service.GetCategorizationStatsAsync();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(3, result.TotalTransactions);
|
Assert.Equal(3, result.TotalTransactions);
|
||||||
Assert.Equal(2, result.Categorized);
|
Assert.Equal(2, result.Categorized);
|
||||||
Assert.Equal(1, result.Uncategorized);
|
Assert.Equal(1, result.Uncategorized);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetCardStatsForAccountAsync_ReturnsStatsForLinkedCards()
|
public async Task GetCardStatsForAccountAsync_ReturnsStatsForLinkedCards()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var context = DbContextHelper.CreateInMemoryContext();
|
using var context = DbContextHelper.CreateInMemoryContext();
|
||||||
var service = new TransactionStatisticsService(context);
|
var service = new TransactionStatisticsService(context);
|
||||||
|
|
||||||
var account = new Account
|
var account = new Account
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Institution = "Test Bank",
|
Institution = "Test Bank",
|
||||||
AccountType = AccountType.Checking,
|
AccountType = AccountType.Checking,
|
||||||
Last4 = "1234",
|
Last4 = "1234",
|
||||||
Owner = "Test Owner"
|
Owner = "Test Owner"
|
||||||
};
|
};
|
||||||
context.Accounts.Add(account);
|
context.Accounts.Add(account);
|
||||||
|
|
||||||
var card1 = new Card
|
var card1 = new Card
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
Issuer = "VISA",
|
Issuer = "VISA",
|
||||||
Last4 = "1111",
|
Last4 = "1111",
|
||||||
Owner = "Test"
|
Owner = "Test"
|
||||||
};
|
};
|
||||||
var card2 = new Card
|
var card2 = new Card
|
||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
Issuer = "Mastercard",
|
Issuer = "Mastercard",
|
||||||
Last4 = "2222",
|
Last4 = "2222",
|
||||||
Owner = "Test"
|
Owner = "Test"
|
||||||
};
|
};
|
||||||
context.Cards.AddRange(card1, card2);
|
context.Cards.AddRange(card1, card2);
|
||||||
|
|
||||||
var transaction1 = new Transaction
|
var transaction1 = new Transaction
|
||||||
{
|
{
|
||||||
Date = DateTime.Now,
|
Date = DateTime.Now,
|
||||||
Amount = -50.00m,
|
Amount = -50.00m,
|
||||||
Name = "Test",
|
Name = "Test",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
CardId = 1
|
CardId = 1
|
||||||
};
|
};
|
||||||
var transaction2 = new Transaction
|
var transaction2 = new Transaction
|
||||||
{
|
{
|
||||||
Date = DateTime.Now,
|
Date = DateTime.Now,
|
||||||
Amount = -30.00m,
|
Amount = -30.00m,
|
||||||
Name = "Test",
|
Name = "Test",
|
||||||
Memo = "Test",
|
Memo = "Test",
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
CardId = 1
|
CardId = 1
|
||||||
};
|
};
|
||||||
context.Transactions.AddRange(transaction1, transaction2);
|
context.Transactions.AddRange(transaction1, transaction2);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.GetCardStatsForAccountAsync(1);
|
var result = await service.GetCardStatsForAccountAsync(1);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(2, result.Count);
|
Assert.Equal(2, result.Count);
|
||||||
var card1Stats = result.First(c => c.Card.Id == 1);
|
var card1Stats = result.First(c => c.Card.Id == 1);
|
||||||
Assert.Equal(2, card1Stats.TransactionCount);
|
Assert.Equal(2, card1Stats.TransactionCount);
|
||||||
var card2Stats = result.First(c => c.Card.Id == 2);
|
var card2Stats = result.First(c => c.Card.Id == 2);
|
||||||
Assert.Equal(0, card2Stats.TransactionCount);
|
Assert.Equal(0, card2Stats.TransactionCount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using MoneyMap.Data;
|
using MoneyMap.Data;
|
||||||
|
|
||||||
namespace MoneyMap.Tests.TestHelpers;
|
namespace MoneyMap.Tests.TestHelpers;
|
||||||
|
|
||||||
public static class DbContextHelper
|
public static class DbContextHelper
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates an in-memory database context for testing.
|
/// Creates an in-memory database context for testing.
|
||||||
/// Each call creates a unique database to ensure test isolation.
|
/// Each call creates a unique database to ensure test isolation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static MoneyMapContext CreateInMemoryContext()
|
public static MoneyMapContext CreateInMemoryContext()
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<MoneyMapContext>()
|
var options = new DbContextOptionsBuilder<MoneyMapContext>()
|
||||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||||
.Options;
|
.Options;
|
||||||
|
|
||||||
return new MoneyMapContext(options);
|
return new MoneyMapContext(options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+79
-79
@@ -1,79 +1,79 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
# Visual Studio Version 17
|
# Visual Studio Version 17
|
||||||
VisualStudioVersion = 17.14.36429.23
|
VisualStudioVersion = 17.14.36429.23
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoneyMap", "MoneyMap\MoneyMap.csproj", "{B273A467-3592-4675-B1EC-C41C9CE455DB}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoneyMap", "MoneyMap\MoneyMap.csproj", "{B273A467-3592-4675-B1EC-C41C9CE455DB}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoneyMap.Tests", "MoneyMap.Tests\MoneyMap.Tests.csproj", "{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoneyMap.Tests", "MoneyMap.Tests\MoneyMap.Tests.csproj", "{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoneyMap.Core", "MoneyMap.Core\MoneyMap.Core.csproj", "{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoneyMap.Core", "MoneyMap.Core\MoneyMap.Core.csproj", "{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoneyMap.Mcp", "MoneyMap.Mcp\MoneyMap.Mcp.csproj", "{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoneyMap.Mcp", "MoneyMap.Mcp\MoneyMap.Mcp.csproj", "{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}"
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
Debug|x64 = Debug|x64
|
Debug|x64 = Debug|x64
|
||||||
Debug|x86 = Debug|x86
|
Debug|x86 = Debug|x86
|
||||||
Release|Any CPU = Release|Any CPU
|
Release|Any CPU = Release|Any CPU
|
||||||
Release|x64 = Release|x64
|
Release|x64 = Release|x64
|
||||||
Release|x86 = Release|x86
|
Release|x86 = Release|x86
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{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|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.ActiveCfg = Debug|Any CPU
|
||||||
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Debug|x64.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Debug|x86.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Release|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Release|x64.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Release|x86.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Debug|x64.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Debug|x86.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Release|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Release|x64.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Release|x86.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Debug|x64.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Debug|x86.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Release|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Release|x64.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Release|x86.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Debug|x64.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Debug|x86.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Release|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Release|x64.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Release|x86.Build.0 = Release|Any CPU
|
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {9BC6A70A-C19A-442D-A77E-74662945CACE}
|
SolutionGuid = {9BC6A70A-C19A-442D-A77E-74662945CACE}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
bin/
|
bin/
|
||||||
obj/
|
obj/
|
||||||
.vs/
|
.vs/
|
||||||
*.user
|
*.user
|
||||||
*.suo
|
*.suo
|
||||||
appsettings.Development.json
|
appsettings.Development.json
|
||||||
wwwroot/receipts/
|
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] int? cardId = null,
|
||||||
[FromQuery] string? type = null,
|
[FromQuery] string? type = null,
|
||||||
[FromQuery] bool? uncategorizedOnly = 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
|
var q = _db.Transactions
|
||||||
.Include(t => t.Merchant)
|
.Include(t => t.Merchant)
|
||||||
.Include(t => t.Card)
|
.Include(t => t.Card)
|
||||||
@@ -75,9 +78,12 @@ public class TransactionsController : ControllerBase
|
|||||||
if (uncategorizedOnly == true)
|
if (uncategorizedOnly == true)
|
||||||
q = q.Where(t => t.Category == null || t.Category == "");
|
q = q.Where(t => t.Category == null || t.Category == "");
|
||||||
|
|
||||||
|
var totalCount = await q.CountAsync();
|
||||||
|
|
||||||
var results = await q
|
var results = await q
|
||||||
.OrderByDescending(t => t.Date).ThenByDescending(t => t.Id)
|
.OrderByDescending(t => t.Date).ThenByDescending(t => t.Id)
|
||||||
.Take(limit ?? 50)
|
.Skip(pageOffset)
|
||||||
|
.Take(pageLimit)
|
||||||
.Select(t => new
|
.Select(t => new
|
||||||
{
|
{
|
||||||
t.Id,
|
t.Id,
|
||||||
@@ -94,7 +100,17 @@ public class TransactionsController : ControllerBase
|
|||||||
})
|
})
|
||||||
.ToListAsync();
|
.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}")]
|
[HttpGet("{id}")]
|
||||||
|
|||||||
+352
-352
@@ -1,352 +1,352 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata;
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
using MoneyMap.Data;
|
using MoneyMap.Data;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace MoneyMap.Migrations
|
namespace MoneyMap.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(MoneyMapContext))]
|
[DbContext(typeof(MoneyMapContext))]
|
||||||
[Migration("20251004000603_InitialCreate")]
|
[Migration("20251004000603_InitialCreate")]
|
||||||
partial class InitialCreate
|
partial class InitialCreate
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.HasAnnotation("ProductVersion", "9.0.9")
|
.HasAnnotation("ProductVersion", "9.0.9")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||||
|
|
||||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
b.Property<string>("Issuer")
|
b.Property<string>("Issuer")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("nvarchar(100)");
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
b.Property<string>("Last4")
|
b.Property<string>("Last4")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(4)
|
.HasMaxLength(4)
|
||||||
.HasColumnType("nvarchar(4)");
|
.HasColumnType("nvarchar(4)");
|
||||||
|
|
||||||
b.Property<string>("Owner")
|
b.Property<string>("Owner")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("nvarchar(100)");
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("Issuer", "Last4", "Owner");
|
b.HasIndex("Issuer", "Last4", "Owner");
|
||||||
|
|
||||||
b.ToTable("Cards");
|
b.ToTable("Cards");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
b.Property<string>("ContentType")
|
b.Property<string>("ContentType")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("nvarchar(100)")
|
.HasColumnType("nvarchar(100)")
|
||||||
.HasDefaultValue("application/octet-stream");
|
.HasDefaultValue("application/octet-stream");
|
||||||
|
|
||||||
b.Property<string>("Currency")
|
b.Property<string>("Currency")
|
||||||
.HasMaxLength(8)
|
.HasMaxLength(8)
|
||||||
.HasColumnType("nvarchar(8)");
|
.HasColumnType("nvarchar(8)");
|
||||||
|
|
||||||
b.Property<string>("FileHashSha256")
|
b.Property<string>("FileHashSha256")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("nvarchar(64)");
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
b.Property<string>("FileName")
|
b.Property<string>("FileName")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(260)
|
.HasMaxLength(260)
|
||||||
.HasColumnType("nvarchar(260)");
|
.HasColumnType("nvarchar(260)");
|
||||||
|
|
||||||
b.Property<long>("FileSizeBytes")
|
b.Property<long>("FileSizeBytes")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
b.Property<string>("Merchant")
|
b.Property<string>("Merchant")
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("nvarchar(200)");
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
b.Property<DateTime?>("ReceiptDate")
|
b.Property<DateTime?>("ReceiptDate")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.Property<string>("StoragePath")
|
b.Property<string>("StoragePath")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(1024)
|
.HasMaxLength(1024)
|
||||||
.HasColumnType("nvarchar(1024)");
|
.HasColumnType("nvarchar(1024)");
|
||||||
|
|
||||||
b.Property<decimal?>("Subtotal")
|
b.Property<decimal?>("Subtotal")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<decimal?>("Tax")
|
b.Property<decimal?>("Tax")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<decimal?>("Total")
|
b.Property<decimal?>("Total")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<long>("TransactionId")
|
b.Property<long>("TransactionId")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
b.Property<DateTime>("UploadedAtUtc")
|
b.Property<DateTime>("UploadedAtUtc")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("TransactionId", "FileHashSha256")
|
b.HasIndex("TransactionId", "FileHashSha256")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("Receipts");
|
b.ToTable("Receipts");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
|
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
b.Property<string>("Category")
|
b.Property<string>("Category")
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("nvarchar(100)");
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
b.Property<decimal?>("Confidence")
|
b.Property<decimal?>("Confidence")
|
||||||
.HasColumnType("decimal(5,4)");
|
.HasColumnType("decimal(5,4)");
|
||||||
|
|
||||||
b.Property<string>("Description")
|
b.Property<string>("Description")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(300)
|
.HasMaxLength(300)
|
||||||
.HasColumnType("nvarchar(300)");
|
.HasColumnType("nvarchar(300)");
|
||||||
|
|
||||||
b.Property<int>("LineNumber")
|
b.Property<int>("LineNumber")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<decimal?>("LineTotal")
|
b.Property<decimal?>("LineTotal")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<decimal?>("Quantity")
|
b.Property<decimal?>("Quantity")
|
||||||
.HasColumnType("decimal(18,4)");
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
b.Property<long>("ReceiptId")
|
b.Property<long>("ReceiptId")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
b.Property<string>("Sku")
|
b.Property<string>("Sku")
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("nvarchar(64)");
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
b.Property<string>("Unit")
|
b.Property<string>("Unit")
|
||||||
.HasMaxLength(16)
|
.HasMaxLength(16)
|
||||||
.HasColumnType("nvarchar(16)");
|
.HasColumnType("nvarchar(16)");
|
||||||
|
|
||||||
b.Property<decimal?>("UnitPrice")
|
b.Property<decimal?>("UnitPrice")
|
||||||
.HasColumnType("decimal(18,4)");
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("ReceiptId", "LineNumber");
|
b.HasIndex("ReceiptId", "LineNumber");
|
||||||
|
|
||||||
b.ToTable("ReceiptLineItems");
|
b.ToTable("ReceiptLineItems");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
|
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
b.Property<DateTime?>("CompletedAtUtc")
|
b.Property<DateTime?>("CompletedAtUtc")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.Property<decimal?>("Confidence")
|
b.Property<decimal?>("Confidence")
|
||||||
.HasColumnType("decimal(5,4)");
|
.HasColumnType("decimal(5,4)");
|
||||||
|
|
||||||
b.Property<string>("Error")
|
b.Property<string>("Error")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("ExtractedTextPath")
|
b.Property<string>("ExtractedTextPath")
|
||||||
.HasMaxLength(1024)
|
.HasMaxLength(1024)
|
||||||
.HasColumnType("nvarchar(1024)");
|
.HasColumnType("nvarchar(1024)");
|
||||||
|
|
||||||
b.Property<string>("Model")
|
b.Property<string>("Model")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("nvarchar(100)");
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
b.Property<string>("Provider")
|
b.Property<string>("Provider")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(50)
|
.HasMaxLength(50)
|
||||||
.HasColumnType("nvarchar(50)");
|
.HasColumnType("nvarchar(50)");
|
||||||
|
|
||||||
b.Property<string>("ProviderJobId")
|
b.Property<string>("ProviderJobId")
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("nvarchar(100)");
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
b.Property<string>("RawProviderPayloadJson")
|
b.Property<string>("RawProviderPayloadJson")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<long>("ReceiptId")
|
b.Property<long>("ReceiptId")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
b.Property<DateTime>("StartedAtUtc")
|
b.Property<DateTime>("StartedAtUtc")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.Property<bool>("Success")
|
b.Property<bool>("Success")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("ReceiptId", "StartedAtUtc");
|
b.HasIndex("ReceiptId", "StartedAtUtc");
|
||||||
|
|
||||||
b.ToTable("ReceiptParseLogs");
|
b.ToTable("ReceiptParseLogs");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
b.Property<decimal>("Amount")
|
b.Property<decimal>("Amount")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<int>("CardId")
|
b.Property<int>("CardId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<string>("CardLast4")
|
b.Property<string>("CardLast4")
|
||||||
.HasMaxLength(4)
|
.HasMaxLength(4)
|
||||||
.HasColumnType("nvarchar(4)");
|
.HasColumnType("nvarchar(4)");
|
||||||
|
|
||||||
b.Property<string>("Category")
|
b.Property<string>("Category")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("nvarchar(100)");
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
b.Property<DateTime>("Date")
|
b.Property<DateTime>("Date")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.Property<string>("Memo")
|
b.Property<string>("Memo")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasMaxLength(500)
|
.HasMaxLength(500)
|
||||||
.HasColumnType("nvarchar(500)")
|
.HasColumnType("nvarchar(500)")
|
||||||
.HasDefaultValue("");
|
.HasDefaultValue("");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("nvarchar(200)");
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
b.Property<string>("TransactionType")
|
b.Property<string>("TransactionType")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(20)
|
.HasMaxLength(20)
|
||||||
.HasColumnType("nvarchar(20)");
|
.HasColumnType("nvarchar(20)");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("Amount");
|
b.HasIndex("Amount");
|
||||||
|
|
||||||
b.HasIndex("CardId");
|
b.HasIndex("CardId");
|
||||||
|
|
||||||
b.HasIndex("Category");
|
b.HasIndex("Category");
|
||||||
|
|
||||||
b.HasIndex("Date");
|
b.HasIndex("Date");
|
||||||
|
|
||||||
b.HasIndex("Date", "Amount", "Name", "Memo", "CardId")
|
b.HasIndex("Date", "Amount", "Name", "Memo", "CardId")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("Transactions");
|
b.ToTable("Transactions");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("MoneyMap.Models.Transaction", "Transaction")
|
b.HasOne("MoneyMap.Models.Transaction", "Transaction")
|
||||||
.WithMany("Receipts")
|
.WithMany("Receipts")
|
||||||
.HasForeignKey("TransactionId")
|
.HasForeignKey("TransactionId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.Navigation("Transaction");
|
b.Navigation("Transaction");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
|
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("MoneyMap.Models.Receipt", "Receipt")
|
b.HasOne("MoneyMap.Models.Receipt", "Receipt")
|
||||||
.WithMany("LineItems")
|
.WithMany("LineItems")
|
||||||
.HasForeignKey("ReceiptId")
|
.HasForeignKey("ReceiptId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.Navigation("Receipt");
|
b.Navigation("Receipt");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
|
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("MoneyMap.Models.Receipt", "Receipt")
|
b.HasOne("MoneyMap.Models.Receipt", "Receipt")
|
||||||
.WithMany("ParseLogs")
|
.WithMany("ParseLogs")
|
||||||
.HasForeignKey("ReceiptId")
|
.HasForeignKey("ReceiptId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.Navigation("Receipt");
|
b.Navigation("Receipt");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("MoneyMap.Models.Card", "Card")
|
b.HasOne("MoneyMap.Models.Card", "Card")
|
||||||
.WithMany("Transactions")
|
.WithMany("Transactions")
|
||||||
.HasForeignKey("CardId")
|
.HasForeignKey("CardId")
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.Navigation("Card");
|
b.Navigation("Card");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Transactions");
|
b.Navigation("Transactions");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("LineItems");
|
b.Navigation("LineItems");
|
||||||
|
|
||||||
b.Navigation("ParseLogs");
|
b.Navigation("ParseLogs");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Receipts");
|
b.Navigation("Receipts");
|
||||||
});
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,209 +1,209 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace MoneyMap.Migrations
|
namespace MoneyMap.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class InitialCreate : Migration
|
public partial class InitialCreate : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "Cards",
|
name: "Cards",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<int>(type: "int", nullable: false)
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
.Annotation("SqlServer:Identity", "1, 1"),
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
Issuer = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
Issuer = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||||
Last4 = table.Column<string>(type: "nvarchar(4)", maxLength: 4, nullable: false),
|
Last4 = table.Column<string>(type: "nvarchar(4)", maxLength: 4, nullable: false),
|
||||||
Owner = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false)
|
Owner = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false)
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_Cards", x => x.Id);
|
table.PrimaryKey("PK_Cards", x => x.Id);
|
||||||
});
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "Transactions",
|
name: "Transactions",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||||
.Annotation("SqlServer:Identity", "1, 1"),
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
Date = table.Column<DateTime>(type: "datetime2", nullable: false),
|
Date = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
TransactionType = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
TransactionType = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, 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: ""),
|
Memo = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""),
|
||||||
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||||
Category = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
Category = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||||
CardId = table.Column<int>(type: "int", nullable: false),
|
CardId = table.Column<int>(type: "int", nullable: false),
|
||||||
CardLast4 = table.Column<string>(type: "nvarchar(4)", maxLength: 4, nullable: true)
|
CardLast4 = table.Column<string>(type: "nvarchar(4)", maxLength: 4, nullable: true)
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_Transactions", x => x.Id);
|
table.PrimaryKey("PK_Transactions", x => x.Id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_Transactions_Cards_CardId",
|
name: "FK_Transactions_Cards_CardId",
|
||||||
column: x => x.CardId,
|
column: x => x.CardId,
|
||||||
principalTable: "Cards",
|
principalTable: "Cards",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Restrict);
|
onDelete: ReferentialAction.Restrict);
|
||||||
});
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "Receipts",
|
name: "Receipts",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||||
.Annotation("SqlServer:Identity", "1, 1"),
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
TransactionId = table.Column<long>(type: "bigint", nullable: false),
|
TransactionId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
FileName = table.Column<string>(type: "nvarchar(260)", maxLength: 260, 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"),
|
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),
|
StoragePath = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: false),
|
||||||
FileSizeBytes = table.Column<long>(type: "bigint", nullable: false),
|
FileSizeBytes = table.Column<long>(type: "bigint", nullable: false),
|
||||||
FileHashSha256 = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
FileHashSha256 = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
UploadedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: false),
|
UploadedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
Merchant = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
Merchant = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||||
ReceiptDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
ReceiptDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
Subtotal = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
Subtotal = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||||
Tax = 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),
|
Total = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||||
Currency = table.Column<string>(type: "nvarchar(8)", maxLength: 8, nullable: true)
|
Currency = table.Column<string>(type: "nvarchar(8)", maxLength: 8, nullable: true)
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_Receipts", x => x.Id);
|
table.PrimaryKey("PK_Receipts", x => x.Id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_Receipts_Transactions_TransactionId",
|
name: "FK_Receipts_Transactions_TransactionId",
|
||||||
column: x => x.TransactionId,
|
column: x => x.TransactionId,
|
||||||
principalTable: "Transactions",
|
principalTable: "Transactions",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade);
|
onDelete: ReferentialAction.Cascade);
|
||||||
});
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "ReceiptLineItems",
|
name: "ReceiptLineItems",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||||
.Annotation("SqlServer:Identity", "1, 1"),
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
ReceiptId = table.Column<long>(type: "bigint", nullable: false),
|
ReceiptId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
LineNumber = table.Column<int>(type: "int", nullable: false),
|
LineNumber = table.Column<int>(type: "int", nullable: false),
|
||||||
Description = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: false),
|
Description = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: false),
|
||||||
Quantity = table.Column<decimal>(type: "decimal(18,4)", nullable: true),
|
Quantity = table.Column<decimal>(type: "decimal(18,4)", nullable: true),
|
||||||
Confidence = table.Column<decimal>(type: "decimal(5,4)", nullable: true),
|
Confidence = table.Column<decimal>(type: "decimal(5,4)", nullable: true),
|
||||||
Unit = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: true),
|
Unit = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: true),
|
||||||
UnitPrice = table.Column<decimal>(type: "decimal(18,4)", nullable: true),
|
UnitPrice = table.Column<decimal>(type: "decimal(18,4)", nullable: true),
|
||||||
LineTotal = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
LineTotal = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||||
Sku = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
Sku = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||||
Category = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true)
|
Category = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true)
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_ReceiptLineItems", x => x.Id);
|
table.PrimaryKey("PK_ReceiptLineItems", x => x.Id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_ReceiptLineItems_Receipts_ReceiptId",
|
name: "FK_ReceiptLineItems_Receipts_ReceiptId",
|
||||||
column: x => x.ReceiptId,
|
column: x => x.ReceiptId,
|
||||||
principalTable: "Receipts",
|
principalTable: "Receipts",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade);
|
onDelete: ReferentialAction.Cascade);
|
||||||
});
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "ReceiptParseLogs",
|
name: "ReceiptParseLogs",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||||
.Annotation("SqlServer:Identity", "1, 1"),
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
ReceiptId = table.Column<long>(type: "bigint", nullable: false),
|
ReceiptId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
Provider = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
Provider = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||||
Model = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
Model = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||||
ProviderJobId = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
ProviderJobId = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||||
StartedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: false),
|
StartedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
CompletedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: true),
|
CompletedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
Success = table.Column<bool>(type: "bit", nullable: false),
|
Success = table.Column<bool>(type: "bit", nullable: false),
|
||||||
Confidence = table.Column<decimal>(type: "decimal(5,4)", nullable: true),
|
Confidence = table.Column<decimal>(type: "decimal(5,4)", nullable: true),
|
||||||
RawProviderPayloadJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
RawProviderPayloadJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
ExtractedTextPath = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
|
ExtractedTextPath = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
|
||||||
Error = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
Error = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_ReceiptParseLogs", x => x.Id);
|
table.PrimaryKey("PK_ReceiptParseLogs", x => x.Id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_ReceiptParseLogs_Receipts_ReceiptId",
|
name: "FK_ReceiptParseLogs_Receipts_ReceiptId",
|
||||||
column: x => x.ReceiptId,
|
column: x => x.ReceiptId,
|
||||||
principalTable: "Receipts",
|
principalTable: "Receipts",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade);
|
onDelete: ReferentialAction.Cascade);
|
||||||
});
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Cards_Issuer_Last4_Owner",
|
name: "IX_Cards_Issuer_Last4_Owner",
|
||||||
table: "Cards",
|
table: "Cards",
|
||||||
columns: new[] { "Issuer", "Last4", "Owner" });
|
columns: new[] { "Issuer", "Last4", "Owner" });
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_ReceiptLineItems_ReceiptId_LineNumber",
|
name: "IX_ReceiptLineItems_ReceiptId_LineNumber",
|
||||||
table: "ReceiptLineItems",
|
table: "ReceiptLineItems",
|
||||||
columns: new[] { "ReceiptId", "LineNumber" });
|
columns: new[] { "ReceiptId", "LineNumber" });
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_ReceiptParseLogs_ReceiptId_StartedAtUtc",
|
name: "IX_ReceiptParseLogs_ReceiptId_StartedAtUtc",
|
||||||
table: "ReceiptParseLogs",
|
table: "ReceiptParseLogs",
|
||||||
columns: new[] { "ReceiptId", "StartedAtUtc" });
|
columns: new[] { "ReceiptId", "StartedAtUtc" });
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Receipts_TransactionId_FileHashSha256",
|
name: "IX_Receipts_TransactionId_FileHashSha256",
|
||||||
table: "Receipts",
|
table: "Receipts",
|
||||||
columns: new[] { "TransactionId", "FileHashSha256" },
|
columns: new[] { "TransactionId", "FileHashSha256" },
|
||||||
unique: true);
|
unique: true);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Transactions_Amount",
|
name: "IX_Transactions_Amount",
|
||||||
table: "Transactions",
|
table: "Transactions",
|
||||||
column: "Amount");
|
column: "Amount");
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Transactions_CardId",
|
name: "IX_Transactions_CardId",
|
||||||
table: "Transactions",
|
table: "Transactions",
|
||||||
column: "CardId");
|
column: "CardId");
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Transactions_Category",
|
name: "IX_Transactions_Category",
|
||||||
table: "Transactions",
|
table: "Transactions",
|
||||||
column: "Category");
|
column: "Category");
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Transactions_Date",
|
name: "IX_Transactions_Date",
|
||||||
table: "Transactions",
|
table: "Transactions",
|
||||||
column: "Date");
|
column: "Date");
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Transactions_Date_Amount_Name_Memo_CardId",
|
name: "IX_Transactions_Date_Amount_Name_Memo_CardId",
|
||||||
table: "Transactions",
|
table: "Transactions",
|
||||||
columns: new[] { "Date", "Amount", "Name", "Memo", "CardId" },
|
columns: new[] { "Date", "Amount", "Name", "Memo", "CardId" },
|
||||||
unique: true);
|
unique: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "ReceiptLineItems");
|
name: "ReceiptLineItems");
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "ReceiptParseLogs");
|
name: "ReceiptParseLogs");
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "Receipts");
|
name: "Receipts");
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "Transactions");
|
name: "Transactions");
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "Cards");
|
name: "Cards");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+373
-373
@@ -1,373 +1,373 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata;
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
using MoneyMap.Data;
|
using MoneyMap.Data;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace MoneyMap.Migrations
|
namespace MoneyMap.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(MoneyMapContext))]
|
[DbContext(typeof(MoneyMapContext))]
|
||||||
[Migration("20251004023633_AddCategoryMappings")]
|
[Migration("20251004023633_AddCategoryMappings")]
|
||||||
partial class AddCategoryMappings
|
partial class AddCategoryMappings
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.HasAnnotation("ProductVersion", "9.0.9")
|
.HasAnnotation("ProductVersion", "9.0.9")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||||
|
|
||||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
b.Property<string>("Issuer")
|
b.Property<string>("Issuer")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("nvarchar(100)");
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
b.Property<string>("Last4")
|
b.Property<string>("Last4")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(4)
|
.HasMaxLength(4)
|
||||||
.HasColumnType("nvarchar(4)");
|
.HasColumnType("nvarchar(4)");
|
||||||
|
|
||||||
b.Property<string>("Owner")
|
b.Property<string>("Owner")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("nvarchar(100)");
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("Issuer", "Last4", "Owner");
|
b.HasIndex("Issuer", "Last4", "Owner");
|
||||||
|
|
||||||
b.ToTable("Cards");
|
b.ToTable("Cards");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
b.Property<string>("ContentType")
|
b.Property<string>("ContentType")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("nvarchar(100)")
|
.HasColumnType("nvarchar(100)")
|
||||||
.HasDefaultValue("application/octet-stream");
|
.HasDefaultValue("application/octet-stream");
|
||||||
|
|
||||||
b.Property<string>("Currency")
|
b.Property<string>("Currency")
|
||||||
.HasMaxLength(8)
|
.HasMaxLength(8)
|
||||||
.HasColumnType("nvarchar(8)");
|
.HasColumnType("nvarchar(8)");
|
||||||
|
|
||||||
b.Property<string>("FileHashSha256")
|
b.Property<string>("FileHashSha256")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("nvarchar(64)");
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
b.Property<string>("FileName")
|
b.Property<string>("FileName")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(260)
|
.HasMaxLength(260)
|
||||||
.HasColumnType("nvarchar(260)");
|
.HasColumnType("nvarchar(260)");
|
||||||
|
|
||||||
b.Property<long>("FileSizeBytes")
|
b.Property<long>("FileSizeBytes")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
b.Property<string>("Merchant")
|
b.Property<string>("Merchant")
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("nvarchar(200)");
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
b.Property<DateTime?>("ReceiptDate")
|
b.Property<DateTime?>("ReceiptDate")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.Property<string>("StoragePath")
|
b.Property<string>("StoragePath")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(1024)
|
.HasMaxLength(1024)
|
||||||
.HasColumnType("nvarchar(1024)");
|
.HasColumnType("nvarchar(1024)");
|
||||||
|
|
||||||
b.Property<decimal?>("Subtotal")
|
b.Property<decimal?>("Subtotal")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<decimal?>("Tax")
|
b.Property<decimal?>("Tax")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<decimal?>("Total")
|
b.Property<decimal?>("Total")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<long>("TransactionId")
|
b.Property<long>("TransactionId")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
b.Property<DateTime>("UploadedAtUtc")
|
b.Property<DateTime>("UploadedAtUtc")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("TransactionId", "FileHashSha256")
|
b.HasIndex("TransactionId", "FileHashSha256")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("Receipts");
|
b.ToTable("Receipts");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
|
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
b.Property<string>("Category")
|
b.Property<string>("Category")
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("nvarchar(100)");
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
b.Property<string>("Description")
|
b.Property<string>("Description")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(300)
|
.HasMaxLength(300)
|
||||||
.HasColumnType("nvarchar(300)");
|
.HasColumnType("nvarchar(300)");
|
||||||
|
|
||||||
b.Property<int>("LineNumber")
|
b.Property<int>("LineNumber")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<decimal?>("LineTotal")
|
b.Property<decimal?>("LineTotal")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<decimal?>("Quantity")
|
b.Property<decimal?>("Quantity")
|
||||||
.HasColumnType("decimal(18,4)");
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
b.Property<long>("ReceiptId")
|
b.Property<long>("ReceiptId")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
b.Property<string>("Sku")
|
b.Property<string>("Sku")
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("nvarchar(64)");
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
b.Property<string>("Unit")
|
b.Property<string>("Unit")
|
||||||
.HasMaxLength(16)
|
.HasMaxLength(16)
|
||||||
.HasColumnType("nvarchar(16)");
|
.HasColumnType("nvarchar(16)");
|
||||||
|
|
||||||
b.Property<decimal?>("UnitPrice")
|
b.Property<decimal?>("UnitPrice")
|
||||||
.HasColumnType("decimal(18,4)");
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("ReceiptId", "LineNumber");
|
b.HasIndex("ReceiptId", "LineNumber");
|
||||||
|
|
||||||
b.ToTable("ReceiptLineItems");
|
b.ToTable("ReceiptLineItems");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
|
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
b.Property<DateTime?>("CompletedAtUtc")
|
b.Property<DateTime?>("CompletedAtUtc")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.Property<decimal?>("Confidence")
|
b.Property<decimal?>("Confidence")
|
||||||
.HasColumnType("decimal(5,4)");
|
.HasColumnType("decimal(5,4)");
|
||||||
|
|
||||||
b.Property<string>("Error")
|
b.Property<string>("Error")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("ExtractedTextPath")
|
b.Property<string>("ExtractedTextPath")
|
||||||
.HasMaxLength(1024)
|
.HasMaxLength(1024)
|
||||||
.HasColumnType("nvarchar(1024)");
|
.HasColumnType("nvarchar(1024)");
|
||||||
|
|
||||||
b.Property<string>("Model")
|
b.Property<string>("Model")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("nvarchar(100)");
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
b.Property<string>("Provider")
|
b.Property<string>("Provider")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(50)
|
.HasMaxLength(50)
|
||||||
.HasColumnType("nvarchar(50)");
|
.HasColumnType("nvarchar(50)");
|
||||||
|
|
||||||
b.Property<string>("ProviderJobId")
|
b.Property<string>("ProviderJobId")
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("nvarchar(100)");
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
b.Property<string>("RawProviderPayloadJson")
|
b.Property<string>("RawProviderPayloadJson")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<long>("ReceiptId")
|
b.Property<long>("ReceiptId")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
b.Property<DateTime>("StartedAtUtc")
|
b.Property<DateTime>("StartedAtUtc")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.Property<bool>("Success")
|
b.Property<bool>("Success")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("ReceiptId", "StartedAtUtc");
|
b.HasIndex("ReceiptId", "StartedAtUtc");
|
||||||
|
|
||||||
b.ToTable("ReceiptParseLogs");
|
b.ToTable("ReceiptParseLogs");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
b.Property<decimal>("Amount")
|
b.Property<decimal>("Amount")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<int>("CardId")
|
b.Property<int>("CardId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<string>("CardLast4")
|
b.Property<string>("CardLast4")
|
||||||
.HasMaxLength(4)
|
.HasMaxLength(4)
|
||||||
.HasColumnType("nvarchar(4)");
|
.HasColumnType("nvarchar(4)");
|
||||||
|
|
||||||
b.Property<string>("Category")
|
b.Property<string>("Category")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("nvarchar(100)");
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
b.Property<DateTime>("Date")
|
b.Property<DateTime>("Date")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.Property<string>("Memo")
|
b.Property<string>("Memo")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasMaxLength(500)
|
.HasMaxLength(500)
|
||||||
.HasColumnType("nvarchar(500)")
|
.HasColumnType("nvarchar(500)")
|
||||||
.HasDefaultValue("");
|
.HasDefaultValue("");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("nvarchar(200)");
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
b.Property<string>("TransactionType")
|
b.Property<string>("TransactionType")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(20)
|
.HasMaxLength(20)
|
||||||
.HasColumnType("nvarchar(20)");
|
.HasColumnType("nvarchar(20)");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("Amount");
|
b.HasIndex("Amount");
|
||||||
|
|
||||||
b.HasIndex("CardId");
|
b.HasIndex("CardId");
|
||||||
|
|
||||||
b.HasIndex("Category");
|
b.HasIndex("Category");
|
||||||
|
|
||||||
b.HasIndex("Date");
|
b.HasIndex("Date");
|
||||||
|
|
||||||
b.HasIndex("Date", "Amount", "Name", "Memo", "CardId")
|
b.HasIndex("Date", "Amount", "Name", "Memo", "CardId")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("Transactions");
|
b.ToTable("Transactions");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b =>
|
modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
b.Property<string>("Category")
|
b.Property<string>("Category")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("Pattern")
|
b.Property<string>("Pattern")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<int>("Priority")
|
b.Property<int>("Priority")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.ToTable("CategoryMappings");
|
b.ToTable("CategoryMappings");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("MoneyMap.Models.Transaction", "Transaction")
|
b.HasOne("MoneyMap.Models.Transaction", "Transaction")
|
||||||
.WithMany("Receipts")
|
.WithMany("Receipts")
|
||||||
.HasForeignKey("TransactionId")
|
.HasForeignKey("TransactionId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.Navigation("Transaction");
|
b.Navigation("Transaction");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
|
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("MoneyMap.Models.Receipt", "Receipt")
|
b.HasOne("MoneyMap.Models.Receipt", "Receipt")
|
||||||
.WithMany("LineItems")
|
.WithMany("LineItems")
|
||||||
.HasForeignKey("ReceiptId")
|
.HasForeignKey("ReceiptId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.Navigation("Receipt");
|
b.Navigation("Receipt");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
|
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("MoneyMap.Models.Receipt", "Receipt")
|
b.HasOne("MoneyMap.Models.Receipt", "Receipt")
|
||||||
.WithMany("ParseLogs")
|
.WithMany("ParseLogs")
|
||||||
.HasForeignKey("ReceiptId")
|
.HasForeignKey("ReceiptId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.Navigation("Receipt");
|
b.Navigation("Receipt");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("MoneyMap.Models.Card", "Card")
|
b.HasOne("MoneyMap.Models.Card", "Card")
|
||||||
.WithMany("Transactions")
|
.WithMany("Transactions")
|
||||||
.HasForeignKey("CardId")
|
.HasForeignKey("CardId")
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.Navigation("Card");
|
b.Navigation("Card");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Transactions");
|
b.Navigation("Transactions");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("LineItems");
|
b.Navigation("LineItems");
|
||||||
|
|
||||||
b.Navigation("ParseLogs");
|
b.Navigation("ParseLogs");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Receipts");
|
b.Navigation("Receipts");
|
||||||
});
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,46 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace MoneyMap.Migrations
|
namespace MoneyMap.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class AddCategoryMappings : Migration
|
public partial class AddCategoryMappings : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropColumn(
|
migrationBuilder.DropColumn(
|
||||||
name: "Confidence",
|
name: "Confidence",
|
||||||
table: "ReceiptLineItems");
|
table: "ReceiptLineItems");
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "CategoryMappings",
|
name: "CategoryMappings",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<int>(type: "int", nullable: false)
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
.Annotation("SqlServer:Identity", "1, 1"),
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
Category = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
Category = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
Pattern = 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)
|
Priority = table.Column<int>(type: "int", nullable: false)
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_CategoryMappings", x => x.Id);
|
table.PrimaryKey("PK_CategoryMappings", x => x.Id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "CategoryMappings");
|
name: "CategoryMappings");
|
||||||
|
|
||||||
migrationBuilder.AddColumn<decimal>(
|
migrationBuilder.AddColumn<decimal>(
|
||||||
name: "Confidence",
|
name: "Confidence",
|
||||||
table: "ReceiptLineItems",
|
table: "ReceiptLineItems",
|
||||||
type: "decimal(5,4)",
|
type: "decimal(5,4)",
|
||||||
nullable: true);
|
nullable: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+377
-377
@@ -1,377 +1,377 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata;
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
using MoneyMap.Data;
|
using MoneyMap.Data;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace MoneyMap.Migrations
|
namespace MoneyMap.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(MoneyMapContext))]
|
[DbContext(typeof(MoneyMapContext))]
|
||||||
[Migration("20251004034919_AddNotesToTransactions")]
|
[Migration("20251004034919_AddNotesToTransactions")]
|
||||||
partial class AddNotesToTransactions
|
partial class AddNotesToTransactions
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.HasAnnotation("ProductVersion", "9.0.9")
|
.HasAnnotation("ProductVersion", "9.0.9")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||||
|
|
||||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
b.Property<string>("Issuer")
|
b.Property<string>("Issuer")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("nvarchar(100)");
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
b.Property<string>("Last4")
|
b.Property<string>("Last4")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(4)
|
.HasMaxLength(4)
|
||||||
.HasColumnType("nvarchar(4)");
|
.HasColumnType("nvarchar(4)");
|
||||||
|
|
||||||
b.Property<string>("Owner")
|
b.Property<string>("Owner")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("nvarchar(100)");
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("Issuer", "Last4", "Owner");
|
b.HasIndex("Issuer", "Last4", "Owner");
|
||||||
|
|
||||||
b.ToTable("Cards");
|
b.ToTable("Cards");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
b.Property<string>("ContentType")
|
b.Property<string>("ContentType")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("nvarchar(100)")
|
.HasColumnType("nvarchar(100)")
|
||||||
.HasDefaultValue("application/octet-stream");
|
.HasDefaultValue("application/octet-stream");
|
||||||
|
|
||||||
b.Property<string>("Currency")
|
b.Property<string>("Currency")
|
||||||
.HasMaxLength(8)
|
.HasMaxLength(8)
|
||||||
.HasColumnType("nvarchar(8)");
|
.HasColumnType("nvarchar(8)");
|
||||||
|
|
||||||
b.Property<string>("FileHashSha256")
|
b.Property<string>("FileHashSha256")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("nvarchar(64)");
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
b.Property<string>("FileName")
|
b.Property<string>("FileName")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(260)
|
.HasMaxLength(260)
|
||||||
.HasColumnType("nvarchar(260)");
|
.HasColumnType("nvarchar(260)");
|
||||||
|
|
||||||
b.Property<long>("FileSizeBytes")
|
b.Property<long>("FileSizeBytes")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
b.Property<string>("Merchant")
|
b.Property<string>("Merchant")
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("nvarchar(200)");
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
b.Property<DateTime?>("ReceiptDate")
|
b.Property<DateTime?>("ReceiptDate")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.Property<string>("StoragePath")
|
b.Property<string>("StoragePath")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(1024)
|
.HasMaxLength(1024)
|
||||||
.HasColumnType("nvarchar(1024)");
|
.HasColumnType("nvarchar(1024)");
|
||||||
|
|
||||||
b.Property<decimal?>("Subtotal")
|
b.Property<decimal?>("Subtotal")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<decimal?>("Tax")
|
b.Property<decimal?>("Tax")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<decimal?>("Total")
|
b.Property<decimal?>("Total")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<long>("TransactionId")
|
b.Property<long>("TransactionId")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
b.Property<DateTime>("UploadedAtUtc")
|
b.Property<DateTime>("UploadedAtUtc")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("TransactionId", "FileHashSha256")
|
b.HasIndex("TransactionId", "FileHashSha256")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("Receipts");
|
b.ToTable("Receipts");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
|
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
b.Property<string>("Category")
|
b.Property<string>("Category")
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("nvarchar(100)");
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
b.Property<string>("Description")
|
b.Property<string>("Description")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(300)
|
.HasMaxLength(300)
|
||||||
.HasColumnType("nvarchar(300)");
|
.HasColumnType("nvarchar(300)");
|
||||||
|
|
||||||
b.Property<int>("LineNumber")
|
b.Property<int>("LineNumber")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<decimal?>("LineTotal")
|
b.Property<decimal?>("LineTotal")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<decimal?>("Quantity")
|
b.Property<decimal?>("Quantity")
|
||||||
.HasColumnType("decimal(18,4)");
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
b.Property<long>("ReceiptId")
|
b.Property<long>("ReceiptId")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
b.Property<string>("Sku")
|
b.Property<string>("Sku")
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("nvarchar(64)");
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
b.Property<string>("Unit")
|
b.Property<string>("Unit")
|
||||||
.HasMaxLength(16)
|
.HasMaxLength(16)
|
||||||
.HasColumnType("nvarchar(16)");
|
.HasColumnType("nvarchar(16)");
|
||||||
|
|
||||||
b.Property<decimal?>("UnitPrice")
|
b.Property<decimal?>("UnitPrice")
|
||||||
.HasColumnType("decimal(18,4)");
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("ReceiptId", "LineNumber");
|
b.HasIndex("ReceiptId", "LineNumber");
|
||||||
|
|
||||||
b.ToTable("ReceiptLineItems");
|
b.ToTable("ReceiptLineItems");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
|
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
b.Property<DateTime?>("CompletedAtUtc")
|
b.Property<DateTime?>("CompletedAtUtc")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.Property<decimal?>("Confidence")
|
b.Property<decimal?>("Confidence")
|
||||||
.HasColumnType("decimal(5,4)");
|
.HasColumnType("decimal(5,4)");
|
||||||
|
|
||||||
b.Property<string>("Error")
|
b.Property<string>("Error")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("ExtractedTextPath")
|
b.Property<string>("ExtractedTextPath")
|
||||||
.HasMaxLength(1024)
|
.HasMaxLength(1024)
|
||||||
.HasColumnType("nvarchar(1024)");
|
.HasColumnType("nvarchar(1024)");
|
||||||
|
|
||||||
b.Property<string>("Model")
|
b.Property<string>("Model")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("nvarchar(100)");
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
b.Property<string>("Provider")
|
b.Property<string>("Provider")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(50)
|
.HasMaxLength(50)
|
||||||
.HasColumnType("nvarchar(50)");
|
.HasColumnType("nvarchar(50)");
|
||||||
|
|
||||||
b.Property<string>("ProviderJobId")
|
b.Property<string>("ProviderJobId")
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("nvarchar(100)");
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
b.Property<string>("RawProviderPayloadJson")
|
b.Property<string>("RawProviderPayloadJson")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<long>("ReceiptId")
|
b.Property<long>("ReceiptId")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
b.Property<DateTime>("StartedAtUtc")
|
b.Property<DateTime>("StartedAtUtc")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.Property<bool>("Success")
|
b.Property<bool>("Success")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("ReceiptId", "StartedAtUtc");
|
b.HasIndex("ReceiptId", "StartedAtUtc");
|
||||||
|
|
||||||
b.ToTable("ReceiptParseLogs");
|
b.ToTable("ReceiptParseLogs");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
b.Property<decimal>("Amount")
|
b.Property<decimal>("Amount")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<int>("CardId")
|
b.Property<int>("CardId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<string>("CardLast4")
|
b.Property<string>("CardLast4")
|
||||||
.HasMaxLength(4)
|
.HasMaxLength(4)
|
||||||
.HasColumnType("nvarchar(4)");
|
.HasColumnType("nvarchar(4)");
|
||||||
|
|
||||||
b.Property<string>("Category")
|
b.Property<string>("Category")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("nvarchar(100)");
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
b.Property<DateTime>("Date")
|
b.Property<DateTime>("Date")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.Property<string>("Memo")
|
b.Property<string>("Memo")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasMaxLength(500)
|
.HasMaxLength(500)
|
||||||
.HasColumnType("nvarchar(500)")
|
.HasColumnType("nvarchar(500)")
|
||||||
.HasDefaultValue("");
|
.HasDefaultValue("");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("nvarchar(200)");
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("TransactionType")
|
b.Property<string>("TransactionType")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(20)
|
.HasMaxLength(20)
|
||||||
.HasColumnType("nvarchar(20)");
|
.HasColumnType("nvarchar(20)");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("Amount");
|
b.HasIndex("Amount");
|
||||||
|
|
||||||
b.HasIndex("CardId");
|
b.HasIndex("CardId");
|
||||||
|
|
||||||
b.HasIndex("Category");
|
b.HasIndex("Category");
|
||||||
|
|
||||||
b.HasIndex("Date");
|
b.HasIndex("Date");
|
||||||
|
|
||||||
b.HasIndex("Date", "Amount", "Name", "Memo", "CardId")
|
b.HasIndex("Date", "Amount", "Name", "Memo", "CardId")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("Transactions");
|
b.ToTable("Transactions");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b =>
|
modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
b.Property<string>("Category")
|
b.Property<string>("Category")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("Pattern")
|
b.Property<string>("Pattern")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<int>("Priority")
|
b.Property<int>("Priority")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.ToTable("CategoryMappings");
|
b.ToTable("CategoryMappings");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("MoneyMap.Models.Transaction", "Transaction")
|
b.HasOne("MoneyMap.Models.Transaction", "Transaction")
|
||||||
.WithMany("Receipts")
|
.WithMany("Receipts")
|
||||||
.HasForeignKey("TransactionId")
|
.HasForeignKey("TransactionId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.Navigation("Transaction");
|
b.Navigation("Transaction");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
|
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("MoneyMap.Models.Receipt", "Receipt")
|
b.HasOne("MoneyMap.Models.Receipt", "Receipt")
|
||||||
.WithMany("LineItems")
|
.WithMany("LineItems")
|
||||||
.HasForeignKey("ReceiptId")
|
.HasForeignKey("ReceiptId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.Navigation("Receipt");
|
b.Navigation("Receipt");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
|
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("MoneyMap.Models.Receipt", "Receipt")
|
b.HasOne("MoneyMap.Models.Receipt", "Receipt")
|
||||||
.WithMany("ParseLogs")
|
.WithMany("ParseLogs")
|
||||||
.HasForeignKey("ReceiptId")
|
.HasForeignKey("ReceiptId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.Navigation("Receipt");
|
b.Navigation("Receipt");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("MoneyMap.Models.Card", "Card")
|
b.HasOne("MoneyMap.Models.Card", "Card")
|
||||||
.WithMany("Transactions")
|
.WithMany("Transactions")
|
||||||
.HasForeignKey("CardId")
|
.HasForeignKey("CardId")
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.Navigation("Card");
|
b.Navigation("Card");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Transactions");
|
b.Navigation("Transactions");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("LineItems");
|
b.Navigation("LineItems");
|
||||||
|
|
||||||
b.Navigation("ParseLogs");
|
b.Navigation("ParseLogs");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Receipts");
|
b.Navigation("Receipts");
|
||||||
});
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace MoneyMap.Migrations
|
namespace MoneyMap.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class AddNotesToTransactions : Migration
|
public partial class AddNotesToTransactions : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "Notes",
|
name: "Notes",
|
||||||
table: "Transactions",
|
table: "Transactions",
|
||||||
type: "nvarchar(max)",
|
type: "nvarchar(max)",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: "");
|
defaultValue: "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropColumn(
|
migrationBuilder.DropColumn(
|
||||||
name: "Notes",
|
name: "Notes",
|
||||||
table: "Transactions");
|
table: "Transactions");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+507
-507
File diff suppressed because it is too large
Load Diff
@@ -1,184 +1,184 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace MoneyMap.Migrations
|
namespace MoneyMap.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class SplitCardsAndAccounts : Migration
|
public partial class SplitCardsAndAccounts : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropIndex(
|
migrationBuilder.DropIndex(
|
||||||
name: "IX_Transactions_Date_Amount_Name_Memo_CardId",
|
name: "IX_Transactions_Date_Amount_Name_Memo_CardId",
|
||||||
table: "Transactions");
|
table: "Transactions");
|
||||||
|
|
||||||
migrationBuilder.RenameColumn(
|
migrationBuilder.RenameColumn(
|
||||||
name: "CardLast4",
|
name: "CardLast4",
|
||||||
table: "Transactions",
|
table: "Transactions",
|
||||||
newName: "Last4");
|
newName: "Last4");
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<int>(
|
migrationBuilder.AlterColumn<int>(
|
||||||
name: "CardId",
|
name: "CardId",
|
||||||
table: "Transactions",
|
table: "Transactions",
|
||||||
type: "int",
|
type: "int",
|
||||||
nullable: true,
|
nullable: true,
|
||||||
oldClrType: typeof(int),
|
oldClrType: typeof(int),
|
||||||
oldType: "int");
|
oldType: "int");
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "AccountId",
|
name: "AccountId",
|
||||||
table: "Transactions",
|
table: "Transactions",
|
||||||
type: "int",
|
type: "int",
|
||||||
nullable: true);
|
nullable: true);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "Accounts",
|
name: "Accounts",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<int>(type: "int", nullable: false)
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
.Annotation("SqlServer:Identity", "1, 1"),
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
Institution = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
Institution = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||||
Last4 = table.Column<string>(type: "nvarchar(4)", maxLength: 4, nullable: false),
|
Last4 = table.Column<string>(type: "nvarchar(4)", maxLength: 4, nullable: false),
|
||||||
Owner = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
Owner = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||||
AccountType = table.Column<int>(type: "int", nullable: false),
|
AccountType = table.Column<int>(type: "int", nullable: false),
|
||||||
Nickname = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true)
|
Nickname = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true)
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_Accounts", x => x.Id);
|
table.PrimaryKey("PK_Accounts", x => x.Id);
|
||||||
});
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "Transfers",
|
name: "Transfers",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||||
.Annotation("SqlServer:Identity", "1, 1"),
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
Date = table.Column<DateTime>(type: "datetime2", nullable: false),
|
Date = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||||
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
|
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
|
||||||
Notes = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
Notes = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
SourceAccountId = table.Column<int>(type: "int", nullable: true),
|
SourceAccountId = table.Column<int>(type: "int", nullable: true),
|
||||||
DestinationAccountId = table.Column<int>(type: "int", nullable: true),
|
DestinationAccountId = table.Column<int>(type: "int", nullable: true),
|
||||||
OriginalTransactionId = table.Column<long>(type: "bigint", nullable: true),
|
OriginalTransactionId = table.Column<long>(type: "bigint", nullable: true),
|
||||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_Transfers", x => x.Id);
|
table.PrimaryKey("PK_Transfers", x => x.Id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_Transfers_Accounts_DestinationAccountId",
|
name: "FK_Transfers_Accounts_DestinationAccountId",
|
||||||
column: x => x.DestinationAccountId,
|
column: x => x.DestinationAccountId,
|
||||||
principalTable: "Accounts",
|
principalTable: "Accounts",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Restrict);
|
onDelete: ReferentialAction.Restrict);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_Transfers_Accounts_SourceAccountId",
|
name: "FK_Transfers_Accounts_SourceAccountId",
|
||||||
column: x => x.SourceAccountId,
|
column: x => x.SourceAccountId,
|
||||||
principalTable: "Accounts",
|
principalTable: "Accounts",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Restrict);
|
onDelete: ReferentialAction.Restrict);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_Transfers_Transactions_OriginalTransactionId",
|
name: "FK_Transfers_Transactions_OriginalTransactionId",
|
||||||
column: x => x.OriginalTransactionId,
|
column: x => x.OriginalTransactionId,
|
||||||
principalTable: "Transactions",
|
principalTable: "Transactions",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.SetNull);
|
onDelete: ReferentialAction.SetNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Transactions_AccountId",
|
name: "IX_Transactions_AccountId",
|
||||||
table: "Transactions",
|
table: "Transactions",
|
||||||
column: "AccountId");
|
column: "AccountId");
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Transactions_Date_Amount_Name_Memo_CardId_AccountId",
|
name: "IX_Transactions_Date_Amount_Name_Memo_CardId_AccountId",
|
||||||
table: "Transactions",
|
table: "Transactions",
|
||||||
columns: new[] { "Date", "Amount", "Name", "Memo", "CardId", "AccountId" },
|
columns: new[] { "Date", "Amount", "Name", "Memo", "CardId", "AccountId" },
|
||||||
unique: true,
|
unique: true,
|
||||||
filter: "[CardId] IS NOT NULL AND [AccountId] IS NOT NULL");
|
filter: "[CardId] IS NOT NULL AND [AccountId] IS NOT NULL");
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Accounts_Institution_Last4_Owner",
|
name: "IX_Accounts_Institution_Last4_Owner",
|
||||||
table: "Accounts",
|
table: "Accounts",
|
||||||
columns: new[] { "Institution", "Last4", "Owner" });
|
columns: new[] { "Institution", "Last4", "Owner" });
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Transfers_Date",
|
name: "IX_Transfers_Date",
|
||||||
table: "Transfers",
|
table: "Transfers",
|
||||||
column: "Date");
|
column: "Date");
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Transfers_DestinationAccountId",
|
name: "IX_Transfers_DestinationAccountId",
|
||||||
table: "Transfers",
|
table: "Transfers",
|
||||||
column: "DestinationAccountId");
|
column: "DestinationAccountId");
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Transfers_OriginalTransactionId",
|
name: "IX_Transfers_OriginalTransactionId",
|
||||||
table: "Transfers",
|
table: "Transfers",
|
||||||
column: "OriginalTransactionId");
|
column: "OriginalTransactionId");
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Transfers_SourceAccountId",
|
name: "IX_Transfers_SourceAccountId",
|
||||||
table: "Transfers",
|
table: "Transfers",
|
||||||
column: "SourceAccountId");
|
column: "SourceAccountId");
|
||||||
|
|
||||||
migrationBuilder.AddForeignKey(
|
migrationBuilder.AddForeignKey(
|
||||||
name: "FK_Transactions_Accounts_AccountId",
|
name: "FK_Transactions_Accounts_AccountId",
|
||||||
table: "Transactions",
|
table: "Transactions",
|
||||||
column: "AccountId",
|
column: "AccountId",
|
||||||
principalTable: "Accounts",
|
principalTable: "Accounts",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Restrict);
|
onDelete: ReferentialAction.Restrict);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropForeignKey(
|
migrationBuilder.DropForeignKey(
|
||||||
name: "FK_Transactions_Accounts_AccountId",
|
name: "FK_Transactions_Accounts_AccountId",
|
||||||
table: "Transactions");
|
table: "Transactions");
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "Transfers");
|
name: "Transfers");
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "Accounts");
|
name: "Accounts");
|
||||||
|
|
||||||
migrationBuilder.DropIndex(
|
migrationBuilder.DropIndex(
|
||||||
name: "IX_Transactions_AccountId",
|
name: "IX_Transactions_AccountId",
|
||||||
table: "Transactions");
|
table: "Transactions");
|
||||||
|
|
||||||
migrationBuilder.DropIndex(
|
migrationBuilder.DropIndex(
|
||||||
name: "IX_Transactions_Date_Amount_Name_Memo_CardId_AccountId",
|
name: "IX_Transactions_Date_Amount_Name_Memo_CardId_AccountId",
|
||||||
table: "Transactions");
|
table: "Transactions");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
migrationBuilder.DropColumn(
|
||||||
name: "AccountId",
|
name: "AccountId",
|
||||||
table: "Transactions");
|
table: "Transactions");
|
||||||
|
|
||||||
migrationBuilder.RenameColumn(
|
migrationBuilder.RenameColumn(
|
||||||
name: "Last4",
|
name: "Last4",
|
||||||
table: "Transactions",
|
table: "Transactions",
|
||||||
newName: "CardLast4");
|
newName: "CardLast4");
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<int>(
|
migrationBuilder.AlterColumn<int>(
|
||||||
name: "CardId",
|
name: "CardId",
|
||||||
table: "Transactions",
|
table: "Transactions",
|
||||||
type: "int",
|
type: "int",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 0,
|
defaultValue: 0,
|
||||||
oldClrType: typeof(int),
|
oldClrType: typeof(int),
|
||||||
oldType: "int",
|
oldType: "int",
|
||||||
oldNullable: true);
|
oldNullable: true);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Transactions_Date_Amount_Name_Memo_CardId",
|
name: "IX_Transactions_Date_Amount_Name_Memo_CardId",
|
||||||
table: "Transactions",
|
table: "Transactions",
|
||||||
columns: new[] { "Date", "Amount", "Name", "Memo", "CardId" },
|
columns: new[] { "Date", "Amount", "Name", "Memo", "CardId" },
|
||||||
unique: true);
|
unique: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+528
-528
File diff suppressed because it is too large
Load Diff
@@ -1,60 +1,60 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace MoneyMap.Migrations
|
namespace MoneyMap.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class LinkCardsToAccounts : Migration
|
public partial class LinkCardsToAccounts : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "AccountId",
|
name: "AccountId",
|
||||||
table: "Cards",
|
table: "Cards",
|
||||||
type: "int",
|
type: "int",
|
||||||
nullable: true);
|
nullable: true);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "Nickname",
|
name: "Nickname",
|
||||||
table: "Cards",
|
table: "Cards",
|
||||||
type: "nvarchar(50)",
|
type: "nvarchar(50)",
|
||||||
maxLength: 50,
|
maxLength: 50,
|
||||||
nullable: true);
|
nullable: true);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Cards_AccountId",
|
name: "IX_Cards_AccountId",
|
||||||
table: "Cards",
|
table: "Cards",
|
||||||
column: "AccountId");
|
column: "AccountId");
|
||||||
|
|
||||||
migrationBuilder.AddForeignKey(
|
migrationBuilder.AddForeignKey(
|
||||||
name: "FK_Cards_Accounts_AccountId",
|
name: "FK_Cards_Accounts_AccountId",
|
||||||
table: "Cards",
|
table: "Cards",
|
||||||
column: "AccountId",
|
column: "AccountId",
|
||||||
principalTable: "Accounts",
|
principalTable: "Accounts",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Restrict);
|
onDelete: ReferentialAction.Restrict);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropForeignKey(
|
migrationBuilder.DropForeignKey(
|
||||||
name: "FK_Cards_Accounts_AccountId",
|
name: "FK_Cards_Accounts_AccountId",
|
||||||
table: "Cards");
|
table: "Cards");
|
||||||
|
|
||||||
migrationBuilder.DropIndex(
|
migrationBuilder.DropIndex(
|
||||||
name: "IX_Cards_AccountId",
|
name: "IX_Cards_AccountId",
|
||||||
table: "Cards");
|
table: "Cards");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
migrationBuilder.DropColumn(
|
||||||
name: "AccountId",
|
name: "AccountId",
|
||||||
table: "Cards");
|
table: "Cards");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
migrationBuilder.DropColumn(
|
||||||
name: "Nickname",
|
name: "Nickname",
|
||||||
table: "Cards");
|
table: "Cards");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+528
-528
File diff suppressed because it is too large
Load Diff
@@ -1,89 +1,89 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace MoneyMap.Migrations
|
namespace MoneyMap.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class MakeAccountRequiredOnTransaction : Migration
|
public partial class MakeAccountRequiredOnTransaction : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropIndex(
|
migrationBuilder.DropIndex(
|
||||||
name: "IX_Transactions_Date_Amount_Name_Memo_CardId_AccountId",
|
name: "IX_Transactions_Date_Amount_Name_Memo_CardId_AccountId",
|
||||||
table: "Transactions");
|
table: "Transactions");
|
||||||
|
|
||||||
// For existing transactions with CardId but no AccountId:
|
// For existing transactions with CardId but no AccountId:
|
||||||
// Set AccountId to the card's linked account if available
|
// Set AccountId to the card's linked account if available
|
||||||
migrationBuilder.Sql(@"
|
migrationBuilder.Sql(@"
|
||||||
UPDATE Transactions
|
UPDATE Transactions
|
||||||
SET AccountId = c.AccountId
|
SET AccountId = c.AccountId
|
||||||
FROM Transactions t
|
FROM Transactions t
|
||||||
INNER JOIN Cards c ON t.CardId = c.Id
|
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
|
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
|
// 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
|
// Delete or move to a default account - for now, we'll prevent the migration if this case exists
|
||||||
migrationBuilder.Sql(@"
|
migrationBuilder.Sql(@"
|
||||||
IF EXISTS (
|
IF EXISTS (
|
||||||
SELECT 1 FROM Transactions t
|
SELECT 1 FROM Transactions t
|
||||||
INNER JOIN Cards c ON t.CardId = c.Id
|
INNER JOIN Cards c ON t.CardId = c.Id
|
||||||
WHERE t.AccountId IS NULL AND c.AccountId IS NULL
|
WHERE t.AccountId IS NULL AND c.AccountId IS NULL
|
||||||
)
|
)
|
||||||
BEGIN
|
BEGIN
|
||||||
RAISERROR('Cannot migrate: Some transactions have cards that are not linked to accounts. Please link all cards to accounts first.', 16, 1)
|
RAISERROR('Cannot migrate: Some transactions have cards that are not linked to accounts. Please link all cards to accounts first.', 16, 1)
|
||||||
END
|
END
|
||||||
");
|
");
|
||||||
|
|
||||||
// For remaining transactions with no AccountId and no CardId, they must be deleted or assigned
|
// For remaining transactions with no AccountId and no CardId, they must be deleted or assigned
|
||||||
migrationBuilder.Sql(@"
|
migrationBuilder.Sql(@"
|
||||||
IF EXISTS (SELECT 1 FROM Transactions WHERE AccountId IS NULL AND CardId IS NULL)
|
IF EXISTS (SELECT 1 FROM Transactions WHERE AccountId IS NULL AND CardId IS NULL)
|
||||||
BEGIN
|
BEGIN
|
||||||
RAISERROR('Cannot migrate: Some transactions have neither AccountId nor CardId. Please fix these transactions first.', 16, 1)
|
RAISERROR('Cannot migrate: Some transactions have neither AccountId nor CardId. Please fix these transactions first.', 16, 1)
|
||||||
END
|
END
|
||||||
");
|
");
|
||||||
|
|
||||||
// Now make AccountId required
|
// Now make AccountId required
|
||||||
migrationBuilder.AlterColumn<int>(
|
migrationBuilder.AlterColumn<int>(
|
||||||
name: "AccountId",
|
name: "AccountId",
|
||||||
table: "Transactions",
|
table: "Transactions",
|
||||||
type: "int",
|
type: "int",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
oldClrType: typeof(int),
|
oldClrType: typeof(int),
|
||||||
oldType: "int",
|
oldType: "int",
|
||||||
oldNullable: true);
|
oldNullable: true);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Transactions_Date_Amount_Name_Memo_AccountId_CardId",
|
name: "IX_Transactions_Date_Amount_Name_Memo_AccountId_CardId",
|
||||||
table: "Transactions",
|
table: "Transactions",
|
||||||
columns: new[] { "Date", "Amount", "Name", "Memo", "AccountId", "CardId" },
|
columns: new[] { "Date", "Amount", "Name", "Memo", "AccountId", "CardId" },
|
||||||
unique: true,
|
unique: true,
|
||||||
filter: "[CardId] IS NOT NULL");
|
filter: "[CardId] IS NOT NULL");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropIndex(
|
migrationBuilder.DropIndex(
|
||||||
name: "IX_Transactions_Date_Amount_Name_Memo_AccountId_CardId",
|
name: "IX_Transactions_Date_Amount_Name_Memo_AccountId_CardId",
|
||||||
table: "Transactions");
|
table: "Transactions");
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<int>(
|
migrationBuilder.AlterColumn<int>(
|
||||||
name: "AccountId",
|
name: "AccountId",
|
||||||
table: "Transactions",
|
table: "Transactions",
|
||||||
type: "int",
|
type: "int",
|
||||||
nullable: true,
|
nullable: true,
|
||||||
oldClrType: typeof(int),
|
oldClrType: typeof(int),
|
||||||
oldType: "int");
|
oldType: "int");
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Transactions_Date_Amount_Name_Memo_CardId_AccountId",
|
name: "IX_Transactions_Date_Amount_Name_Memo_CardId_AccountId",
|
||||||
table: "Transactions",
|
table: "Transactions",
|
||||||
columns: new[] { "Date", "Amount", "Name", "Memo", "CardId", "AccountId" },
|
columns: new[] { "Date", "Amount", "Name", "Memo", "CardId", "AccountId" },
|
||||||
unique: true,
|
unique: true,
|
||||||
filter: "[CardId] IS NOT NULL AND [AccountId] IS NOT NULL");
|
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;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace MoneyMap.Migrations
|
namespace MoneyMap.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class AddTransferSupport : Migration
|
public partial class AddTransferSupport : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "TransferToAccountId",
|
name: "TransferToAccountId",
|
||||||
table: "Transactions",
|
table: "Transactions",
|
||||||
type: "int",
|
type: "int",
|
||||||
nullable: true);
|
nullable: true);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Transactions_TransferToAccountId",
|
name: "IX_Transactions_TransferToAccountId",
|
||||||
table: "Transactions",
|
table: "Transactions",
|
||||||
column: "TransferToAccountId");
|
column: "TransferToAccountId");
|
||||||
|
|
||||||
migrationBuilder.AddForeignKey(
|
migrationBuilder.AddForeignKey(
|
||||||
name: "FK_Transactions_Accounts_TransferToAccountId",
|
name: "FK_Transactions_Accounts_TransferToAccountId",
|
||||||
table: "Transactions",
|
table: "Transactions",
|
||||||
column: "TransferToAccountId",
|
column: "TransferToAccountId",
|
||||||
principalTable: "Accounts",
|
principalTable: "Accounts",
|
||||||
principalColumn: "Id");
|
principalColumn: "Id");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropForeignKey(
|
migrationBuilder.DropForeignKey(
|
||||||
name: "FK_Transactions_Accounts_TransferToAccountId",
|
name: "FK_Transactions_Accounts_TransferToAccountId",
|
||||||
table: "Transactions");
|
table: "Transactions");
|
||||||
|
|
||||||
migrationBuilder.DropIndex(
|
migrationBuilder.DropIndex(
|
||||||
name: "IX_Transactions_TransferToAccountId",
|
name: "IX_Transactions_TransferToAccountId",
|
||||||
table: "Transactions");
|
table: "Transactions");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
migrationBuilder.DropColumn(
|
||||||
name: "TransferToAccountId",
|
name: "TransferToAccountId",
|
||||||
table: "Transactions");
|
table: "Transactions");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+543
-543
File diff suppressed because it is too large
Load Diff
@@ -1,29 +1,29 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace MoneyMap.Migrations
|
namespace MoneyMap.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class AddMerchantToTransactions : Migration
|
public partial class AddMerchantToTransactions : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "Merchant",
|
name: "Merchant",
|
||||||
table: "Transactions",
|
table: "Transactions",
|
||||||
type: "nvarchar(100)",
|
type: "nvarchar(100)",
|
||||||
maxLength: 100,
|
maxLength: 100,
|
||||||
nullable: true);
|
nullable: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropColumn(
|
migrationBuilder.DropColumn(
|
||||||
name: "Merchant",
|
name: "Merchant",
|
||||||
table: "Transactions");
|
table: "Transactions");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+546
-546
File diff suppressed because it is too large
Load Diff
@@ -1,28 +1,28 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace MoneyMap.Migrations
|
namespace MoneyMap.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class AddMerchantToCategoryMappings : Migration
|
public partial class AddMerchantToCategoryMappings : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "Merchant",
|
name: "Merchant",
|
||||||
table: "CategoryMappings",
|
table: "CategoryMappings",
|
||||||
type: "nvarchar(max)",
|
type: "nvarchar(max)",
|
||||||
nullable: true);
|
nullable: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropColumn(
|
migrationBuilder.DropColumn(
|
||||||
name: "Merchant",
|
name: "Merchant",
|
||||||
table: "CategoryMappings");
|
table: "CategoryMappings");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+596
-596
File diff suppressed because it is too large
Load Diff
@@ -1,159 +1,159 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace MoneyMap.Migrations
|
namespace MoneyMap.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class ConvertMerchantToEntity : Migration
|
public partial class ConvertMerchantToEntity : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropColumn(
|
migrationBuilder.DropColumn(
|
||||||
name: "Merchant",
|
name: "Merchant",
|
||||||
table: "Transactions");
|
table: "Transactions");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
migrationBuilder.DropColumn(
|
||||||
name: "Merchant",
|
name: "Merchant",
|
||||||
table: "CategoryMappings");
|
table: "CategoryMappings");
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "MerchantId",
|
name: "MerchantId",
|
||||||
table: "Transactions",
|
table: "Transactions",
|
||||||
type: "int",
|
type: "int",
|
||||||
nullable: true);
|
nullable: true);
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<string>(
|
migrationBuilder.AlterColumn<string>(
|
||||||
name: "Pattern",
|
name: "Pattern",
|
||||||
table: "CategoryMappings",
|
table: "CategoryMappings",
|
||||||
type: "nvarchar(200)",
|
type: "nvarchar(200)",
|
||||||
maxLength: 200,
|
maxLength: 200,
|
||||||
nullable: false,
|
nullable: false,
|
||||||
oldClrType: typeof(string),
|
oldClrType: typeof(string),
|
||||||
oldType: "nvarchar(max)");
|
oldType: "nvarchar(max)");
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<string>(
|
migrationBuilder.AlterColumn<string>(
|
||||||
name: "Category",
|
name: "Category",
|
||||||
table: "CategoryMappings",
|
table: "CategoryMappings",
|
||||||
type: "nvarchar(100)",
|
type: "nvarchar(100)",
|
||||||
maxLength: 100,
|
maxLength: 100,
|
||||||
nullable: false,
|
nullable: false,
|
||||||
oldClrType: typeof(string),
|
oldClrType: typeof(string),
|
||||||
oldType: "nvarchar(max)");
|
oldType: "nvarchar(max)");
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "MerchantId",
|
name: "MerchantId",
|
||||||
table: "CategoryMappings",
|
table: "CategoryMappings",
|
||||||
type: "int",
|
type: "int",
|
||||||
nullable: true);
|
nullable: true);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "Merchants",
|
name: "Merchants",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<int>(type: "int", nullable: false)
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
.Annotation("SqlServer:Identity", "1, 1"),
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
Name = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false)
|
Name = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false)
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_Merchants", x => x.Id);
|
table.PrimaryKey("PK_Merchants", x => x.Id);
|
||||||
});
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Transactions_MerchantId",
|
name: "IX_Transactions_MerchantId",
|
||||||
table: "Transactions",
|
table: "Transactions",
|
||||||
column: "MerchantId");
|
column: "MerchantId");
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_CategoryMappings_MerchantId",
|
name: "IX_CategoryMappings_MerchantId",
|
||||||
table: "CategoryMappings",
|
table: "CategoryMappings",
|
||||||
column: "MerchantId");
|
column: "MerchantId");
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Merchants_Name",
|
name: "IX_Merchants_Name",
|
||||||
table: "Merchants",
|
table: "Merchants",
|
||||||
column: "Name",
|
column: "Name",
|
||||||
unique: true);
|
unique: true);
|
||||||
|
|
||||||
migrationBuilder.AddForeignKey(
|
migrationBuilder.AddForeignKey(
|
||||||
name: "FK_CategoryMappings_Merchants_MerchantId",
|
name: "FK_CategoryMappings_Merchants_MerchantId",
|
||||||
table: "CategoryMappings",
|
table: "CategoryMappings",
|
||||||
column: "MerchantId",
|
column: "MerchantId",
|
||||||
principalTable: "Merchants",
|
principalTable: "Merchants",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.SetNull);
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
|
||||||
migrationBuilder.AddForeignKey(
|
migrationBuilder.AddForeignKey(
|
||||||
name: "FK_Transactions_Merchants_MerchantId",
|
name: "FK_Transactions_Merchants_MerchantId",
|
||||||
table: "Transactions",
|
table: "Transactions",
|
||||||
column: "MerchantId",
|
column: "MerchantId",
|
||||||
principalTable: "Merchants",
|
principalTable: "Merchants",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.SetNull);
|
onDelete: ReferentialAction.SetNull);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropForeignKey(
|
migrationBuilder.DropForeignKey(
|
||||||
name: "FK_CategoryMappings_Merchants_MerchantId",
|
name: "FK_CategoryMappings_Merchants_MerchantId",
|
||||||
table: "CategoryMappings");
|
table: "CategoryMappings");
|
||||||
|
|
||||||
migrationBuilder.DropForeignKey(
|
migrationBuilder.DropForeignKey(
|
||||||
name: "FK_Transactions_Merchants_MerchantId",
|
name: "FK_Transactions_Merchants_MerchantId",
|
||||||
table: "Transactions");
|
table: "Transactions");
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "Merchants");
|
name: "Merchants");
|
||||||
|
|
||||||
migrationBuilder.DropIndex(
|
migrationBuilder.DropIndex(
|
||||||
name: "IX_Transactions_MerchantId",
|
name: "IX_Transactions_MerchantId",
|
||||||
table: "Transactions");
|
table: "Transactions");
|
||||||
|
|
||||||
migrationBuilder.DropIndex(
|
migrationBuilder.DropIndex(
|
||||||
name: "IX_CategoryMappings_MerchantId",
|
name: "IX_CategoryMappings_MerchantId",
|
||||||
table: "CategoryMappings");
|
table: "CategoryMappings");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
migrationBuilder.DropColumn(
|
||||||
name: "MerchantId",
|
name: "MerchantId",
|
||||||
table: "Transactions");
|
table: "Transactions");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
migrationBuilder.DropColumn(
|
||||||
name: "MerchantId",
|
name: "MerchantId",
|
||||||
table: "CategoryMappings");
|
table: "CategoryMappings");
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "Merchant",
|
name: "Merchant",
|
||||||
table: "Transactions",
|
table: "Transactions",
|
||||||
type: "nvarchar(100)",
|
type: "nvarchar(100)",
|
||||||
maxLength: 100,
|
maxLength: 100,
|
||||||
nullable: true);
|
nullable: true);
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<string>(
|
migrationBuilder.AlterColumn<string>(
|
||||||
name: "Pattern",
|
name: "Pattern",
|
||||||
table: "CategoryMappings",
|
table: "CategoryMappings",
|
||||||
type: "nvarchar(max)",
|
type: "nvarchar(max)",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
oldClrType: typeof(string),
|
oldClrType: typeof(string),
|
||||||
oldType: "nvarchar(200)",
|
oldType: "nvarchar(200)",
|
||||||
oldMaxLength: 200);
|
oldMaxLength: 200);
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<string>(
|
migrationBuilder.AlterColumn<string>(
|
||||||
name: "Category",
|
name: "Category",
|
||||||
table: "CategoryMappings",
|
table: "CategoryMappings",
|
||||||
type: "nvarchar(max)",
|
type: "nvarchar(max)",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
oldClrType: typeof(string),
|
oldClrType: typeof(string),
|
||||||
oldType: "nvarchar(100)",
|
oldType: "nvarchar(100)",
|
||||||
oldMaxLength: 100);
|
oldMaxLength: 100);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "Merchant",
|
name: "Merchant",
|
||||||
table: "CategoryMappings",
|
table: "CategoryMappings",
|
||||||
type: "nvarchar(max)",
|
type: "nvarchar(max)",
|
||||||
nullable: true);
|
nullable: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+606
-606
File diff suppressed because it is too large
Load Diff
@@ -1,49 +1,49 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace MoneyMap.Migrations
|
namespace MoneyMap.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class AddAICategorization : Migration
|
public partial class AddAICategorization : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<decimal>(
|
migrationBuilder.AddColumn<decimal>(
|
||||||
name: "Confidence",
|
name: "Confidence",
|
||||||
table: "CategoryMappings",
|
table: "CategoryMappings",
|
||||||
type: "decimal(5,4)",
|
type: "decimal(5,4)",
|
||||||
nullable: true);
|
nullable: true);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<DateTime>(
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
name: "CreatedAt",
|
name: "CreatedAt",
|
||||||
table: "CategoryMappings",
|
table: "CategoryMappings",
|
||||||
type: "datetime2",
|
type: "datetime2",
|
||||||
nullable: true);
|
nullable: true);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "CreatedBy",
|
name: "CreatedBy",
|
||||||
table: "CategoryMappings",
|
table: "CategoryMappings",
|
||||||
type: "nvarchar(50)",
|
type: "nvarchar(50)",
|
||||||
maxLength: 50,
|
maxLength: 50,
|
||||||
nullable: true);
|
nullable: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropColumn(
|
migrationBuilder.DropColumn(
|
||||||
name: "Confidence",
|
name: "Confidence",
|
||||||
table: "CategoryMappings");
|
table: "CategoryMappings");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
migrationBuilder.DropColumn(
|
||||||
name: "CreatedAt",
|
name: "CreatedAt",
|
||||||
table: "CategoryMappings");
|
table: "CategoryMappings");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
migrationBuilder.DropColumn(
|
||||||
name: "CreatedBy",
|
name: "CreatedBy",
|
||||||
table: "CategoryMappings");
|
table: "CategoryMappings");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+606
-606
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,22 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace MoneyMap.Migrations
|
namespace MoneyMap.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class MakeReceiptTransactionIdNullable : Migration
|
public partial class MakeReceiptTransactionIdNullable : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
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;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace MoneyMap.Migrations
|
namespace MoneyMap.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class UpdateReceiptIndexForNullableTransactionId : Migration
|
public partial class UpdateReceiptIndexForNullableTransactionId : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropIndex(
|
migrationBuilder.DropIndex(
|
||||||
name: "IX_Receipts_TransactionId_FileHashSha256",
|
name: "IX_Receipts_TransactionId_FileHashSha256",
|
||||||
table: "Receipts");
|
table: "Receipts");
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<long>(
|
migrationBuilder.AlterColumn<long>(
|
||||||
name: "TransactionId",
|
name: "TransactionId",
|
||||||
table: "Receipts",
|
table: "Receipts",
|
||||||
type: "bigint",
|
type: "bigint",
|
||||||
nullable: true,
|
nullable: true,
|
||||||
oldClrType: typeof(long),
|
oldClrType: typeof(long),
|
||||||
oldType: "bigint");
|
oldType: "bigint");
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Receipts_TransactionId_FileHashSha256",
|
name: "IX_Receipts_TransactionId_FileHashSha256",
|
||||||
table: "Receipts",
|
table: "Receipts",
|
||||||
columns: new[] { "TransactionId", "FileHashSha256" },
|
columns: new[] { "TransactionId", "FileHashSha256" },
|
||||||
unique: true,
|
unique: true,
|
||||||
filter: "[TransactionId] IS NOT NULL");
|
filter: "[TransactionId] IS NOT NULL");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropIndex(
|
migrationBuilder.DropIndex(
|
||||||
name: "IX_Receipts_TransactionId_FileHashSha256",
|
name: "IX_Receipts_TransactionId_FileHashSha256",
|
||||||
table: "Receipts");
|
table: "Receipts");
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<long>(
|
migrationBuilder.AlterColumn<long>(
|
||||||
name: "TransactionId",
|
name: "TransactionId",
|
||||||
table: "Receipts",
|
table: "Receipts",
|
||||||
type: "bigint",
|
type: "bigint",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 0L,
|
defaultValue: 0L,
|
||||||
oldClrType: typeof(long),
|
oldClrType: typeof(long),
|
||||||
oldType: "bigint",
|
oldType: "bigint",
|
||||||
oldNullable: true);
|
oldNullable: true);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Receipts_TransactionId_FileHashSha256",
|
name: "IX_Receipts_TransactionId_FileHashSha256",
|
||||||
table: "Receipts",
|
table: "Receipts",
|
||||||
columns: new[] { "TransactionId", "FileHashSha256" },
|
columns: new[] { "TransactionId", "FileHashSha256" },
|
||||||
unique: true);
|
unique: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+606
-606
File diff suppressed because it is too large
Load Diff
@@ -1,57 +1,57 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace MoneyMap.Migrations
|
namespace MoneyMap.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class MakeReceiptTransactionOptional : Migration
|
public partial class MakeReceiptTransactionOptional : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropIndex(
|
migrationBuilder.DropIndex(
|
||||||
name: "IX_Receipts_TransactionId_FileHashSha256",
|
name: "IX_Receipts_TransactionId_FileHashSha256",
|
||||||
table: "Receipts");
|
table: "Receipts");
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<long>(
|
migrationBuilder.AlterColumn<long>(
|
||||||
name: "TransactionId",
|
name: "TransactionId",
|
||||||
table: "Receipts",
|
table: "Receipts",
|
||||||
type: "bigint",
|
type: "bigint",
|
||||||
nullable: true,
|
nullable: true,
|
||||||
oldClrType: typeof(long),
|
oldClrType: typeof(long),
|
||||||
oldType: "bigint");
|
oldType: "bigint");
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Receipts_TransactionId_FileHashSha256",
|
name: "IX_Receipts_TransactionId_FileHashSha256",
|
||||||
table: "Receipts",
|
table: "Receipts",
|
||||||
columns: new[] { "TransactionId", "FileHashSha256" },
|
columns: new[] { "TransactionId", "FileHashSha256" },
|
||||||
unique: true,
|
unique: true,
|
||||||
filter: "[TransactionId] IS NOT NULL");
|
filter: "[TransactionId] IS NOT NULL");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropIndex(
|
migrationBuilder.DropIndex(
|
||||||
name: "IX_Receipts_TransactionId_FileHashSha256",
|
name: "IX_Receipts_TransactionId_FileHashSha256",
|
||||||
table: "Receipts");
|
table: "Receipts");
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<long>(
|
migrationBuilder.AlterColumn<long>(
|
||||||
name: "TransactionId",
|
name: "TransactionId",
|
||||||
table: "Receipts",
|
table: "Receipts",
|
||||||
type: "bigint",
|
type: "bigint",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 0L,
|
defaultValue: 0L,
|
||||||
oldClrType: typeof(long),
|
oldClrType: typeof(long),
|
||||||
oldType: "bigint",
|
oldType: "bigint",
|
||||||
oldNullable: true);
|
oldNullable: true);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Receipts_TransactionId_FileHashSha256",
|
name: "IX_Receipts_TransactionId_FileHashSha256",
|
||||||
table: "Receipts",
|
table: "Receipts",
|
||||||
columns: new[] { "TransactionId", "FileHashSha256" },
|
columns: new[] { "TransactionId", "FileHashSha256" },
|
||||||
unique: true);
|
unique: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+609
-609
File diff suppressed because it is too large
Load Diff
@@ -1,28 +1,28 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace MoneyMap.Migrations
|
namespace MoneyMap.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class AddReceiptDueDate : Migration
|
public partial class AddReceiptDueDate : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<DateTime>(
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
name: "DueDate",
|
name: "DueDate",
|
||||||
table: "Receipts",
|
table: "Receipts",
|
||||||
type: "datetime2",
|
type: "datetime2",
|
||||||
nullable: true);
|
nullable: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropColumn(
|
migrationBuilder.DropColumn(
|
||||||
name: "DueDate",
|
name: "DueDate",
|
||||||
table: "Receipts");
|
table: "Receipts");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+612
-612
File diff suppressed because it is too large
Load Diff
@@ -1,29 +1,29 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace MoneyMap.Migrations
|
namespace MoneyMap.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class AddVoidedToReceiptLineItem : Migration
|
public partial class AddVoidedToReceiptLineItem : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<bool>(
|
migrationBuilder.AddColumn<bool>(
|
||||||
name: "Voided",
|
name: "Voided",
|
||||||
table: "ReceiptLineItems",
|
table: "ReceiptLineItems",
|
||||||
type: "bit",
|
type: "bit",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: false);
|
defaultValue: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropColumn(
|
migrationBuilder.DropColumn(
|
||||||
name: "Voided",
|
name: "Voided",
|
||||||
table: "ReceiptLineItems");
|
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