Initial commit: Purrse personal finance app
Self-hosted, plugin-extensible personal finance manager built with ASP.NET Core 9.0, Vue 3 + Vuetify 3, and PostgreSQL 17. Backend (8 .NET projects): - Core: 19 domain models, 6 enums, 14 DTOs, 12 service interfaces - Data: EF Core DbContext, 16 entity configurations, category seeder - API: 14 controllers, 15 services, JWT auth, SignalR, middleware - Plugins: Abstractions + OFX/CSV/QIF file parsers - Tests: 28 xUnit tests (fingerprinting, duplicate detection, parsers) Frontend (Vue 3 + Vuetify 3 + TypeScript): - 13 views, Pinia stores, Axios API services with JWT interceptors - Dashboard, accounts, transactions, categories, imports, and more Deployment: - Docker Compose (PostgreSQL 17 + .NET API + nginx frontend) - Auto-migration on startup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
DB_PASSWORD=change_me_in_production
|
||||||
|
JWT_KEY=YourSuperSecretKeyHere_AtLeast32Characters!
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
# .NET
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
*.cache
|
||||||
|
*.dll
|
||||||
|
*.pdb
|
||||||
|
*.exe
|
||||||
|
.vs/
|
||||||
|
*.DotSettings.user
|
||||||
|
|
||||||
|
# Node / Frontend
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/.vite/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
Thumbs.db
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# Plugins and imports (runtime data)
|
||||||
|
plugins/
|
||||||
|
imports/
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.0.31903.59
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Purrse.Core", "src\Purrse.Core\Purrse.Core.csproj", "{A417D901-AA67-4B72-93D0-93D99F246AED}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Purrse.Data", "src\Purrse.Data\Purrse.Data.csproj", "{B9FD024E-D5E9-4B61-A007-6478DBF82DD7}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Purrse.Api", "src\Purrse.Api\Purrse.Api.csproj", "{1C11BED2-5D3E-45F2-A6E7-D48131AE8A84}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Purrse.Plugins.Abstractions", "src\Purrse.Plugins.Abstractions\Purrse.Plugins.Abstractions.csproj", "{A9444014-87F9-4FDB-8326-0C77049A14A1}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Purrse.Plugins.OFX", "src\Purrse.Plugins.OFX\Purrse.Plugins.OFX.csproj", "{A252FD29-DCB8-43F9-B25B-ABD57CFE1BBE}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Purrse.Plugins.CSV", "src\Purrse.Plugins.CSV\Purrse.Plugins.CSV.csproj", "{C93480E6-AEF4-4665-9F8C-032F36DC6AD3}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Purrse.Plugins.QIF", "src\Purrse.Plugins.QIF\Purrse.Plugins.QIF.csproj", "{3CA0D3FF-F269-42D8-A8D4-AB77A34CBF0B}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Purrse.Tests", "src\Purrse.Tests\Purrse.Tests.csproj", "{8A090385-C08B-4BCB-A1C9-61C2D23E9C49}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Debug|x64 = Debug|x64
|
||||||
|
Debug|x86 = Debug|x86
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
Release|x64 = Release|x64
|
||||||
|
Release|x86 = Release|x86
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{A417D901-AA67-4B72-93D0-93D99F246AED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A417D901-AA67-4B72-93D0-93D99F246AED}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A417D901-AA67-4B72-93D0-93D99F246AED}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{A417D901-AA67-4B72-93D0-93D99F246AED}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{A417D901-AA67-4B72-93D0-93D99F246AED}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{A417D901-AA67-4B72-93D0-93D99F246AED}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{A417D901-AA67-4B72-93D0-93D99F246AED}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{A417D901-AA67-4B72-93D0-93D99F246AED}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{A417D901-AA67-4B72-93D0-93D99F246AED}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{A417D901-AA67-4B72-93D0-93D99F246AED}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{A417D901-AA67-4B72-93D0-93D99F246AED}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{A417D901-AA67-4B72-93D0-93D99F246AED}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{B9FD024E-D5E9-4B61-A007-6478DBF82DD7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{B9FD024E-D5E9-4B61-A007-6478DBF82DD7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{B9FD024E-D5E9-4B61-A007-6478DBF82DD7}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{B9FD024E-D5E9-4B61-A007-6478DBF82DD7}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{B9FD024E-D5E9-4B61-A007-6478DBF82DD7}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{B9FD024E-D5E9-4B61-A007-6478DBF82DD7}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{B9FD024E-D5E9-4B61-A007-6478DBF82DD7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{B9FD024E-D5E9-4B61-A007-6478DBF82DD7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{B9FD024E-D5E9-4B61-A007-6478DBF82DD7}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{B9FD024E-D5E9-4B61-A007-6478DBF82DD7}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{B9FD024E-D5E9-4B61-A007-6478DBF82DD7}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{B9FD024E-D5E9-4B61-A007-6478DBF82DD7}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{1C11BED2-5D3E-45F2-A6E7-D48131AE8A84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{1C11BED2-5D3E-45F2-A6E7-D48131AE8A84}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{1C11BED2-5D3E-45F2-A6E7-D48131AE8A84}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{1C11BED2-5D3E-45F2-A6E7-D48131AE8A84}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{1C11BED2-5D3E-45F2-A6E7-D48131AE8A84}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{1C11BED2-5D3E-45F2-A6E7-D48131AE8A84}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{1C11BED2-5D3E-45F2-A6E7-D48131AE8A84}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{1C11BED2-5D3E-45F2-A6E7-D48131AE8A84}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{1C11BED2-5D3E-45F2-A6E7-D48131AE8A84}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{1C11BED2-5D3E-45F2-A6E7-D48131AE8A84}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{1C11BED2-5D3E-45F2-A6E7-D48131AE8A84}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{1C11BED2-5D3E-45F2-A6E7-D48131AE8A84}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{A9444014-87F9-4FDB-8326-0C77049A14A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A9444014-87F9-4FDB-8326-0C77049A14A1}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A9444014-87F9-4FDB-8326-0C77049A14A1}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{A9444014-87F9-4FDB-8326-0C77049A14A1}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{A9444014-87F9-4FDB-8326-0C77049A14A1}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{A9444014-87F9-4FDB-8326-0C77049A14A1}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{A9444014-87F9-4FDB-8326-0C77049A14A1}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{A9444014-87F9-4FDB-8326-0C77049A14A1}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{A9444014-87F9-4FDB-8326-0C77049A14A1}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{A9444014-87F9-4FDB-8326-0C77049A14A1}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{A9444014-87F9-4FDB-8326-0C77049A14A1}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{A9444014-87F9-4FDB-8326-0C77049A14A1}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{A252FD29-DCB8-43F9-B25B-ABD57CFE1BBE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A252FD29-DCB8-43F9-B25B-ABD57CFE1BBE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A252FD29-DCB8-43F9-B25B-ABD57CFE1BBE}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{A252FD29-DCB8-43F9-B25B-ABD57CFE1BBE}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{A252FD29-DCB8-43F9-B25B-ABD57CFE1BBE}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{A252FD29-DCB8-43F9-B25B-ABD57CFE1BBE}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{A252FD29-DCB8-43F9-B25B-ABD57CFE1BBE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{A252FD29-DCB8-43F9-B25B-ABD57CFE1BBE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{A252FD29-DCB8-43F9-B25B-ABD57CFE1BBE}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{A252FD29-DCB8-43F9-B25B-ABD57CFE1BBE}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{A252FD29-DCB8-43F9-B25B-ABD57CFE1BBE}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{A252FD29-DCB8-43F9-B25B-ABD57CFE1BBE}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{C93480E6-AEF4-4665-9F8C-032F36DC6AD3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{C93480E6-AEF4-4665-9F8C-032F36DC6AD3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{C93480E6-AEF4-4665-9F8C-032F36DC6AD3}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{C93480E6-AEF4-4665-9F8C-032F36DC6AD3}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{C93480E6-AEF4-4665-9F8C-032F36DC6AD3}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{C93480E6-AEF4-4665-9F8C-032F36DC6AD3}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{C93480E6-AEF4-4665-9F8C-032F36DC6AD3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{C93480E6-AEF4-4665-9F8C-032F36DC6AD3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{C93480E6-AEF4-4665-9F8C-032F36DC6AD3}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{C93480E6-AEF4-4665-9F8C-032F36DC6AD3}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{C93480E6-AEF4-4665-9F8C-032F36DC6AD3}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{C93480E6-AEF4-4665-9F8C-032F36DC6AD3}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{3CA0D3FF-F269-42D8-A8D4-AB77A34CBF0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{3CA0D3FF-F269-42D8-A8D4-AB77A34CBF0B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{3CA0D3FF-F269-42D8-A8D4-AB77A34CBF0B}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{3CA0D3FF-F269-42D8-A8D4-AB77A34CBF0B}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{3CA0D3FF-F269-42D8-A8D4-AB77A34CBF0B}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{3CA0D3FF-F269-42D8-A8D4-AB77A34CBF0B}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{3CA0D3FF-F269-42D8-A8D4-AB77A34CBF0B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{3CA0D3FF-F269-42D8-A8D4-AB77A34CBF0B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{3CA0D3FF-F269-42D8-A8D4-AB77A34CBF0B}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{3CA0D3FF-F269-42D8-A8D4-AB77A34CBF0B}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{3CA0D3FF-F269-42D8-A8D4-AB77A34CBF0B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{3CA0D3FF-F269-42D8-A8D4-AB77A34CBF0B}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{8A090385-C08B-4BCB-A1C9-61C2D23E9C49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{8A090385-C08B-4BCB-A1C9-61C2D23E9C49}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{8A090385-C08B-4BCB-A1C9-61C2D23E9C49}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{8A090385-C08B-4BCB-A1C9-61C2D23E9C49}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{8A090385-C08B-4BCB-A1C9-61C2D23E9C49}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{8A090385-C08B-4BCB-A1C9-61C2D23E9C49}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{8A090385-C08B-4BCB-A1C9-61C2D23E9C49}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{8A090385-C08B-4BCB-A1C9-61C2D23E9C49}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{8A090385-C08B-4BCB-A1C9-61C2D23E9C49}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{8A090385-C08B-4BCB-A1C9-61C2D23E9C49}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{8A090385-C08B-4BCB-A1C9-61C2D23E9C49}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{8A090385-C08B-4BCB-A1C9-61C2D23E9C49}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
{A417D901-AA67-4B72-93D0-93D99F246AED} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{B9FD024E-D5E9-4B61-A007-6478DBF82DD7} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{1C11BED2-5D3E-45F2-A6E7-D48131AE8A84} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{A9444014-87F9-4FDB-8326-0C77049A14A1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{A252FD29-DCB8-43F9-B25B-ABD57CFE1BBE} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{C93480E6-AEF4-4665-9F8C-032F36DC6AD3} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{3CA0D3FF-F269-42D8-A8D4-AB77A34CBF0B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{8A090385-C08B-4BCB-A1C9-61C2D23E9C49} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
container_name: purrse-db
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: purrse
|
||||||
|
POSTGRES_USER: purrse
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-purrse_dev}
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
volumes:
|
||||||
|
- purrse-db-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U purrse"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: src/Purrse.Api/Dockerfile
|
||||||
|
container_name: purrse-api
|
||||||
|
environment:
|
||||||
|
- ConnectionStrings__DefaultConnection=Host=db;Port=5432;Database=purrse;Username=purrse;Password=${DB_PASSWORD:-purrse_dev}
|
||||||
|
- Jwt__Key=${JWT_KEY:-PurrseDefaultSecretKey_ChangeInProduction_AtLeast32Chars!}
|
||||||
|
- Jwt__Issuer=Purrse
|
||||||
|
- Jwt__Audience=Purrse
|
||||||
|
- Cors__Origins__0=http://localhost:8080
|
||||||
|
- Plugins__Path=/app/plugins
|
||||||
|
- Imports__WatchPath=/app/imports
|
||||||
|
ports:
|
||||||
|
- "5000:8080"
|
||||||
|
volumes:
|
||||||
|
- purrse-plugins:/app/plugins
|
||||||
|
- purrse-imports:/app/imports
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: purrse-frontend
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
purrse-db-data:
|
||||||
|
purrse-plugins:
|
||||||
|
purrse-imports:
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Purrse - Personal Finance</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /hubs/ {
|
||||||
|
proxy_pass http://api:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "purrse-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mdi/font": "^7.4.47",
|
||||||
|
"@microsoft/signalr": "^9.0.0",
|
||||||
|
"apexcharts": "^4.3.0",
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"pinia": "^2.3.0",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.0",
|
||||||
|
"vue3-apexcharts": "^1.7.0",
|
||||||
|
"vuetify": "^3.7.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"sass": "^1.83.0",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^6.0.5",
|
||||||
|
"vue-tsc": "^2.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<v-app>
|
||||||
|
<AppLayout v-if="authStore.isAuthenticated">
|
||||||
|
<router-view />
|
||||||
|
</AppLayout>
|
||||||
|
<router-view v-else />
|
||||||
|
</v-app>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
authStore.initialize()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<template>
|
||||||
|
<v-navigation-drawer v-model="drawer" :rail="rail" permanent>
|
||||||
|
<v-list-item
|
||||||
|
prepend-icon="mdi-cat"
|
||||||
|
title="Purrse"
|
||||||
|
subtitle="Personal Finance"
|
||||||
|
nav
|
||||||
|
>
|
||||||
|
<template v-slot:append>
|
||||||
|
<v-btn
|
||||||
|
:icon="rail ? 'mdi-chevron-right' : 'mdi-chevron-left'"
|
||||||
|
variant="text"
|
||||||
|
@click="rail = !rail"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-list density="compact" nav>
|
||||||
|
<v-list-item
|
||||||
|
v-for="item in navItems"
|
||||||
|
:key="item.route"
|
||||||
|
:prepend-icon="item.icon"
|
||||||
|
:title="item.title"
|
||||||
|
:to="item.route"
|
||||||
|
:value="item.route"
|
||||||
|
rounded="xl"
|
||||||
|
/>
|
||||||
|
</v-list>
|
||||||
|
|
||||||
|
<template v-slot:append>
|
||||||
|
<v-divider />
|
||||||
|
<v-list density="compact" nav>
|
||||||
|
<v-list-item
|
||||||
|
prepend-icon="mdi-logout"
|
||||||
|
title="Logout"
|
||||||
|
@click="handleLogout"
|
||||||
|
rounded="xl"
|
||||||
|
/>
|
||||||
|
</v-list>
|
||||||
|
</template>
|
||||||
|
</v-navigation-drawer>
|
||||||
|
|
||||||
|
<v-app-bar density="compact" flat>
|
||||||
|
<v-app-bar-title>
|
||||||
|
<span class="text-body-1 font-weight-medium">{{ currentPageTitle }}</span>
|
||||||
|
</v-app-bar-title>
|
||||||
|
|
||||||
|
<template v-slot:append>
|
||||||
|
<v-btn
|
||||||
|
:icon="isDark ? 'mdi-weather-sunny' : 'mdi-weather-night'"
|
||||||
|
@click="toggleTheme"
|
||||||
|
/>
|
||||||
|
<v-btn icon="mdi-bell-outline" />
|
||||||
|
<v-menu>
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-btn icon v-bind="props">
|
||||||
|
<v-avatar color="primary" size="32">
|
||||||
|
<span class="text-body-2">{{ userInitial }}</span>
|
||||||
|
</v-avatar>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
<v-list density="compact">
|
||||||
|
<v-list-item prepend-icon="mdi-account" :title="authStore.username || 'User'" />
|
||||||
|
<v-divider />
|
||||||
|
<v-list-item prepend-icon="mdi-logout" title="Logout" @click="handleLogout" />
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
</template>
|
||||||
|
</v-app-bar>
|
||||||
|
|
||||||
|
<v-main>
|
||||||
|
<v-container fluid class="pa-6">
|
||||||
|
<slot />
|
||||||
|
</v-container>
|
||||||
|
</v-main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useTheme } from 'vuetify'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const drawer = ref(true)
|
||||||
|
const rail = ref(false)
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const theme = useTheme()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const isDark = computed(() => theme.global.current.value.dark)
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ title: 'Dashboard', icon: 'mdi-view-dashboard', route: '/' },
|
||||||
|
{ title: 'Accounts', icon: 'mdi-bank', route: '/accounts' },
|
||||||
|
{ title: 'Transactions', icon: 'mdi-swap-horizontal', route: '/transactions' },
|
||||||
|
{ title: 'Categories', icon: 'mdi-tag-multiple', route: '/categories' },
|
||||||
|
{ title: 'Budgets', icon: 'mdi-calculator', route: '/budgets' },
|
||||||
|
{ title: 'Scheduled', icon: 'mdi-clock-outline', route: '/scheduled' },
|
||||||
|
{ title: 'Imports', icon: 'mdi-file-upload', route: '/imports' },
|
||||||
|
{ title: 'Loans', icon: 'mdi-home-city', route: '/loans' },
|
||||||
|
{ title: 'Reports', icon: 'mdi-chart-bar', route: '/reports' },
|
||||||
|
{ title: 'Investments', icon: 'mdi-finance', route: '/investments' },
|
||||||
|
{ title: 'Plugins', icon: 'mdi-puzzle', route: '/plugins' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const currentPageTitle = computed(() => {
|
||||||
|
const item = navItems.find(n => n.route === route.path)
|
||||||
|
return item?.title || 'Purrse'
|
||||||
|
})
|
||||||
|
|
||||||
|
const userInitial = computed(() => {
|
||||||
|
return authStore.username?.charAt(0).toUpperCase() || 'U'
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
theme.global.name.value = isDark.value ? 'light' : 'dark'
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
authStore.logout()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import vuetify from './plugins/vuetify'
|
||||||
|
import '@mdi/font/css/materialdesignicons.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.use(vuetify)
|
||||||
|
app.mount('#app')
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{ path: '/login', name: 'login', component: () => import('@/views/auth/LoginView.vue'), meta: { guest: true } },
|
||||||
|
{ path: '/register', name: 'register', component: () => import('@/views/auth/RegisterView.vue'), meta: { guest: true } },
|
||||||
|
{ path: '/', name: 'dashboard', component: () => import('@/views/dashboard/DashboardView.vue'), meta: { auth: true } },
|
||||||
|
{ path: '/accounts', name: 'accounts', component: () => import('@/views/accounts/AccountsView.vue'), meta: { auth: true } },
|
||||||
|
{ path: '/accounts/:id/transactions', name: 'account-transactions', component: () => import('@/views/transactions/TransactionsView.vue'), meta: { auth: true }, props: true },
|
||||||
|
{ path: '/transactions', name: 'transactions', component: () => import('@/views/transactions/TransactionsView.vue'), meta: { auth: true } },
|
||||||
|
{ path: '/categories', name: 'categories', component: () => import('@/views/categories/CategoriesView.vue'), meta: { auth: true } },
|
||||||
|
{ path: '/budgets', name: 'budgets', component: () => import('@/views/budgets/BudgetsView.vue'), meta: { auth: true } },
|
||||||
|
{ path: '/scheduled', name: 'scheduled', component: () => import('@/views/scheduled/ScheduledView.vue'), meta: { auth: true } },
|
||||||
|
{ path: '/imports', name: 'imports', component: () => import('@/views/imports/ImportsView.vue'), meta: { auth: true } },
|
||||||
|
{ path: '/loans/:id?', name: 'loans', component: () => import('@/views/loans/LoansView.vue'), meta: { auth: true }, props: true },
|
||||||
|
{ path: '/reports', name: 'reports', component: () => import('@/views/reports/ReportsView.vue'), meta: { auth: true } },
|
||||||
|
{ path: '/investments', name: 'investments', component: () => import('@/views/investments/InvestmentsView.vue'), meta: { auth: true } },
|
||||||
|
{ path: '/plugins', name: 'plugins', component: () => import('@/views/plugins/PluginsView.vue'), meta: { auth: true } },
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to, _from, next) => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
if (to.meta.auth && !authStore.isAuthenticated) {
|
||||||
|
next({ name: 'login' })
|
||||||
|
} else if (to.meta.guest && authStore.isAuthenticated) {
|
||||||
|
next({ name: 'dashboard' })
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import api from './api'
|
||||||
|
import type { Account } from '@/types'
|
||||||
|
|
||||||
|
export const accountsApi = {
|
||||||
|
getAll: () => api.get<Account[]>('/accounts'),
|
||||||
|
getById: (id: string) => api.get<Account>(`/accounts/${id}`),
|
||||||
|
create: (data: any) => api.post<Account>('/accounts', data),
|
||||||
|
update: (id: string, data: any) => api.put<Account>(`/accounts/${id}`, data),
|
||||||
|
delete: (id: string) => api.delete(`/accounts/${id}`),
|
||||||
|
getBalanceHistory: (id: string, startDate: string, endDate: string) =>
|
||||||
|
api.get(`/accounts/${id}/balance-history`, { params: { startDate, endDate } }),
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import router from '@/router'
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.request.use(config => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
if (authStore.token) {
|
||||||
|
config.headers.Authorization = `Bearer ${authStore.token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
response => response,
|
||||||
|
async error => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
try {
|
||||||
|
await authStore.refreshToken()
|
||||||
|
error.config.headers.Authorization = `Bearer ${authStore.token}`
|
||||||
|
return api(error.config)
|
||||||
|
} catch {
|
||||||
|
authStore.logout()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default api
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import api from './api'
|
||||||
|
import type { Category, CategoryTree } from '@/types'
|
||||||
|
|
||||||
|
export const categoriesApi = {
|
||||||
|
getAll: () => api.get<Category[]>('/categories'),
|
||||||
|
getTree: () => api.get<CategoryTree[]>('/categories/tree'),
|
||||||
|
create: (data: any) => api.post<Category>('/categories', data),
|
||||||
|
update: (id: string, data: any) => api.put<Category>(`/categories/${id}`, data),
|
||||||
|
delete: (id: string) => api.delete(`/categories/${id}`),
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import api from './api'
|
||||||
|
import type { Transaction, PagedResult } from '@/types'
|
||||||
|
|
||||||
|
export const transactionsApi = {
|
||||||
|
getByAccount: (accountId: string, page = 1, pageSize = 50) =>
|
||||||
|
api.get<PagedResult<Transaction>>(`/transactions/account/${accountId}`, { params: { page, pageSize } }),
|
||||||
|
getById: (id: string) => api.get<Transaction>(`/transactions/${id}`),
|
||||||
|
create: (data: any) => api.post<Transaction>('/transactions', data),
|
||||||
|
update: (id: string, data: any) => api.put<Transaction>(`/transactions/${id}`, data),
|
||||||
|
delete: (id: string) => api.delete(`/transactions/${id}`),
|
||||||
|
search: (data: any) => api.post<PagedResult<Transaction>>('/transactions/search', data),
|
||||||
|
createTransfer: (data: any) => api.post('/transactions/transfer', data),
|
||||||
|
void: (id: string) => api.post(`/transactions/${id}/void`),
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { accountsApi } from '@/services/accounts'
|
||||||
|
import type { Account } from '@/types'
|
||||||
|
|
||||||
|
export const useAccountsStore = defineStore('accounts', () => {
|
||||||
|
const accounts = ref<Account[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function fetchAccounts() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await accountsApi.getAll()
|
||||||
|
accounts.value = data
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAccount(accountData: any) {
|
||||||
|
const { data } = await accountsApi.create(accountData)
|
||||||
|
accounts.value.push(data)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateAccount(id: string, accountData: any) {
|
||||||
|
const { data } = await accountsApi.update(id, accountData)
|
||||||
|
const index = accounts.value.findIndex(a => a.id === id)
|
||||||
|
if (index >= 0) accounts.value[index] = data
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAccount(id: string) {
|
||||||
|
await accountsApi.delete(id)
|
||||||
|
accounts.value = accounts.value.filter(a => a.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { accounts, loading, fetchAccounts, createAccount, updateAccount, deleteAccount }
|
||||||
|
})
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import api from '@/services/api'
|
||||||
|
import type { AuthResponse } from '@/types'
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const token = ref<string | null>(null)
|
||||||
|
const refreshTokenValue = ref<string | null>(null)
|
||||||
|
const username = ref<string | null>(null)
|
||||||
|
const expiresAt = ref<string | null>(null)
|
||||||
|
|
||||||
|
const isAuthenticated = computed(() => !!token.value)
|
||||||
|
|
||||||
|
function initialize() {
|
||||||
|
token.value = localStorage.getItem('purrse_token')
|
||||||
|
refreshTokenValue.value = localStorage.getItem('purrse_refresh_token')
|
||||||
|
username.value = localStorage.getItem('purrse_username')
|
||||||
|
expiresAt.value = localStorage.getItem('purrse_expires_at')
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAuth(auth: AuthResponse) {
|
||||||
|
token.value = auth.token
|
||||||
|
refreshTokenValue.value = auth.refreshToken
|
||||||
|
username.value = auth.username
|
||||||
|
expiresAt.value = auth.expiresAt
|
||||||
|
localStorage.setItem('purrse_token', auth.token)
|
||||||
|
localStorage.setItem('purrse_refresh_token', auth.refreshToken)
|
||||||
|
localStorage.setItem('purrse_username', auth.username)
|
||||||
|
localStorage.setItem('purrse_expires_at', auth.expiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(usernameInput: string, password: string) {
|
||||||
|
const { data } = await api.post<AuthResponse>('/auth/login', { username: usernameInput, password })
|
||||||
|
setAuth(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register(usernameInput: string, email: string, password: string) {
|
||||||
|
const { data } = await api.post<AuthResponse>('/auth/register', { username: usernameInput, email, password })
|
||||||
|
setAuth(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshToken() {
|
||||||
|
if (!refreshTokenValue.value) throw new Error('No refresh token')
|
||||||
|
const { data } = await api.post<AuthResponse>('/auth/refresh', { refreshToken: refreshTokenValue.value })
|
||||||
|
setAuth(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
token.value = null
|
||||||
|
refreshTokenValue.value = null
|
||||||
|
username.value = null
|
||||||
|
expiresAt.value = null
|
||||||
|
localStorage.removeItem('purrse_token')
|
||||||
|
localStorage.removeItem('purrse_refresh_token')
|
||||||
|
localStorage.removeItem('purrse_username')
|
||||||
|
localStorage.removeItem('purrse_expires_at')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { token, username, expiresAt, isAuthenticated, initialize, login, register, refreshToken, logout }
|
||||||
|
})
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
export interface AuthResponse {
|
||||||
|
token: string
|
||||||
|
refreshToken: string
|
||||||
|
expiresAt: string
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Account {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: AccountType
|
||||||
|
institution: string | null
|
||||||
|
accountNumber: string | null
|
||||||
|
balance: number
|
||||||
|
creditLimit: number | null
|
||||||
|
interestRate: number | null
|
||||||
|
isActive: boolean
|
||||||
|
isClosed: boolean
|
||||||
|
notes: string | null
|
||||||
|
sortOrder: number
|
||||||
|
createdAt: string
|
||||||
|
hasLoanDetail: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountSummary {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: AccountType
|
||||||
|
institution: string | null
|
||||||
|
balance: number
|
||||||
|
creditLimit: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AccountType = 'Checking' | 'Savings' | 'CreditCard' | 'AutoLoan' | 'PersonalLoan' | 'Mortgage' | 'LineOfCredit' | 'Retirement401k' | 'IRA' | 'Brokerage' | 'Cash'
|
||||||
|
|
||||||
|
export interface Transaction {
|
||||||
|
id: string
|
||||||
|
accountId: string
|
||||||
|
accountName: string | null
|
||||||
|
date: string
|
||||||
|
amount: number
|
||||||
|
payeeId: string | null
|
||||||
|
payeeName: string | null
|
||||||
|
categoryId: string | null
|
||||||
|
categoryName: string | null
|
||||||
|
memo: string | null
|
||||||
|
referenceNumber: string | null
|
||||||
|
checkNumber: string | null
|
||||||
|
type: TransactionType
|
||||||
|
status: TransactionStatus
|
||||||
|
isVoid: boolean
|
||||||
|
transferTransactionId: string | null
|
||||||
|
importBatchId: string | null
|
||||||
|
splits: TransactionSplit[] | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TransactionType = 'Debit' | 'Credit' | 'Transfer'
|
||||||
|
export type TransactionStatus = 'Uncleared' | 'Cleared' | 'Reconciled'
|
||||||
|
|
||||||
|
export interface TransactionSplit {
|
||||||
|
id: string
|
||||||
|
categoryId: string | null
|
||||||
|
categoryName: string | null
|
||||||
|
amount: number
|
||||||
|
memo: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: CategoryType
|
||||||
|
parentId: string | null
|
||||||
|
parentName: string | null
|
||||||
|
sortOrder: number
|
||||||
|
isSystem: boolean
|
||||||
|
isActive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryTree {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: CategoryType
|
||||||
|
sortOrder: number
|
||||||
|
isSystem: boolean
|
||||||
|
isActive: boolean
|
||||||
|
children: CategoryTree[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CategoryType = 'Income' | 'Expense' | 'Transfer'
|
||||||
|
|
||||||
|
export interface Payee {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
defaultCategoryId: string | null
|
||||||
|
defaultCategoryName: string | null
|
||||||
|
isActive: boolean
|
||||||
|
aliases: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Budget {
|
||||||
|
id: string
|
||||||
|
year: number
|
||||||
|
month: number
|
||||||
|
notes: string | null
|
||||||
|
items: BudgetItem[]
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BudgetItem {
|
||||||
|
id: string
|
||||||
|
categoryId: string
|
||||||
|
categoryName: string
|
||||||
|
budgetedAmount: number
|
||||||
|
actualAmount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduledTransaction {
|
||||||
|
id: string
|
||||||
|
accountId: string
|
||||||
|
accountName: string
|
||||||
|
amount: number
|
||||||
|
payeeName: string | null
|
||||||
|
categoryId: string | null
|
||||||
|
categoryName: string | null
|
||||||
|
memo: string | null
|
||||||
|
type: TransactionType
|
||||||
|
frequency: Frequency
|
||||||
|
nextDueDate: string
|
||||||
|
endDate: string | null
|
||||||
|
autoPost: boolean
|
||||||
|
reminderDaysBefore: number
|
||||||
|
isActive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Frequency = 'Daily' | 'Weekly' | 'BiWeekly' | 'SemiMonthly' | 'Monthly' | 'Quarterly' | 'SemiAnnually' | 'Annually'
|
||||||
|
|
||||||
|
export interface DashboardData {
|
||||||
|
netWorth: number
|
||||||
|
totalAssets: number
|
||||||
|
totalLiabilities: number
|
||||||
|
accounts: AccountSummary[]
|
||||||
|
recentTransactions: Transaction[]
|
||||||
|
spendingSummary: SpendingSummary
|
||||||
|
upcomingBills: UpcomingBill[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpendingSummary {
|
||||||
|
thisMonth: number
|
||||||
|
lastMonth: number
|
||||||
|
changePercent: number
|
||||||
|
topCategories: CategorySpending[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategorySpending {
|
||||||
|
categoryName: string
|
||||||
|
amount: number
|
||||||
|
percentage: number
|
||||||
|
parentCategoryName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpcomingBill {
|
||||||
|
id: string
|
||||||
|
payeeName: string
|
||||||
|
amount: number
|
||||||
|
dueDate: string
|
||||||
|
accountName: string
|
||||||
|
autoPost: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedResult<T> {
|
||||||
|
items: T[]
|
||||||
|
totalCount: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoanDetail {
|
||||||
|
id: string
|
||||||
|
accountId: string
|
||||||
|
accountName: string
|
||||||
|
originalBalance: number
|
||||||
|
currentBalance: number
|
||||||
|
interestRate: number
|
||||||
|
termMonths: number
|
||||||
|
monthlyPayment: number
|
||||||
|
originationDate: string
|
||||||
|
maturityDate: string
|
||||||
|
escrowAmount: number | null
|
||||||
|
extraPayment: number | null
|
||||||
|
amortizationSchedule: AmortizationEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AmortizationEntry {
|
||||||
|
paymentNumber: number
|
||||||
|
paymentDate: string
|
||||||
|
paymentAmount: number
|
||||||
|
principalAmount: number
|
||||||
|
interestAmount: number
|
||||||
|
escrowAmount: number | null
|
||||||
|
remainingBalance: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PayoffScenario {
|
||||||
|
originalPayoffDate: string
|
||||||
|
newPayoffDate: string
|
||||||
|
totalInterestOriginal: number
|
||||||
|
totalInterestNew: number
|
||||||
|
interestSaved: number
|
||||||
|
monthsSaved: number
|
||||||
|
newSchedule: AmortizationEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportUploadResponse {
|
||||||
|
batchId: string
|
||||||
|
totalTransactions: number
|
||||||
|
newTransactions: number
|
||||||
|
possibleDuplicates: number
|
||||||
|
previews: ImportPreview[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportPreview {
|
||||||
|
index: number
|
||||||
|
date: string
|
||||||
|
amount: number
|
||||||
|
payeeName: string | null
|
||||||
|
memo: string | null
|
||||||
|
fitId: string | null
|
||||||
|
duplicateMatch: DuplicateMatch | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DuplicateMatch {
|
||||||
|
existingTransactionId: string
|
||||||
|
confidence: number
|
||||||
|
matchReason: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { format, parseISO } from 'date-fns'
|
||||||
|
|
||||||
|
export function formatCurrency(amount: number): string {
|
||||||
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: string): string {
|
||||||
|
return format(parseISO(date), 'MM/dd/yyyy')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(date: string): string {
|
||||||
|
return format(parseISO(date), 'MM/dd/yyyy h:mm a')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPercent(value: number): string {
|
||||||
|
return `${value >= 0 ? '+' : ''}${value.toFixed(1)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function accountTypeLabel(type: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
Checking: 'Checking',
|
||||||
|
Savings: 'Savings',
|
||||||
|
CreditCard: 'Credit Card',
|
||||||
|
AutoLoan: 'Auto Loan',
|
||||||
|
PersonalLoan: 'Personal Loan',
|
||||||
|
Mortgage: 'Mortgage',
|
||||||
|
LineOfCredit: 'Line of Credit',
|
||||||
|
Retirement401k: '401(k)',
|
||||||
|
IRA: 'IRA',
|
||||||
|
Brokerage: 'Brokerage',
|
||||||
|
Cash: 'Cash',
|
||||||
|
}
|
||||||
|
return labels[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
export function accountTypeIcon(type: string): string {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
Checking: 'mdi-bank',
|
||||||
|
Savings: 'mdi-piggy-bank',
|
||||||
|
CreditCard: 'mdi-credit-card',
|
||||||
|
AutoLoan: 'mdi-car',
|
||||||
|
PersonalLoan: 'mdi-cash-multiple',
|
||||||
|
Mortgage: 'mdi-home',
|
||||||
|
LineOfCredit: 'mdi-credit-card-outline',
|
||||||
|
Retirement401k: 'mdi-chart-line',
|
||||||
|
IRA: 'mdi-chart-areaspline',
|
||||||
|
Brokerage: 'mdi-finance',
|
||||||
|
Cash: 'mdi-cash',
|
||||||
|
}
|
||||||
|
return icons[type] || 'mdi-bank'
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="d-flex align-center mb-4">
|
||||||
|
<h1 class="text-h4">Accounts</h1>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-plus" @click="showDialog = true">Add Account</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col v-for="account in accountsStore.accounts" :key="account.id" cols="12" md="6" lg="4">
|
||||||
|
<v-card :to="`/accounts/${account.id}/transactions`" hover>
|
||||||
|
<v-card-title>
|
||||||
|
<v-icon class="mr-2">{{ accountTypeIcon(account.type) }}</v-icon>
|
||||||
|
{{ account.name }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-subtitle>{{ account.institution }} - {{ accountTypeLabel(account.type) }}</v-card-subtitle>
|
||||||
|
<v-card-text>
|
||||||
|
<div class="text-h4" :class="account.balance >= 0 ? 'text-success' : 'text-error'">
|
||||||
|
{{ formatCurrency(account.balance) }}
|
||||||
|
</div>
|
||||||
|
<div v-if="account.creditLimit" class="text-caption mt-1">
|
||||||
|
Credit Limit: {{ formatCurrency(account.creditLimit) }}
|
||||||
|
({{ ((Math.abs(account.balance) / account.creditLimit) * 100).toFixed(0) }}% used)
|
||||||
|
</div>
|
||||||
|
<div v-if="account.interestRate" class="text-caption">APR: {{ account.interestRate }}%</div>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-btn size="small" variant="text" @click.prevent="editAccount(account)">Edit</v-btn>
|
||||||
|
<v-btn v-if="account.hasLoanDetail" size="small" variant="text" color="info" :to="`/loans/${account.id}`" @click.stop>Loan Details</v-btn>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn size="small" variant="text" color="error" @click.prevent="confirmDelete(account)">Delete</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-dialog v-model="showDialog" max-width="600">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>{{ editing ? 'Edit Account' : 'Add Account' }}</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-text-field v-model="form.name" label="Account Name" required />
|
||||||
|
<v-select v-model="form.type" label="Account Type" :items="accountTypes" item-title="label" item-value="value" />
|
||||||
|
<v-text-field v-model="form.institution" label="Institution" />
|
||||||
|
<v-text-field v-model="form.accountNumber" label="Account Number (last 4)" maxlength="4" />
|
||||||
|
<v-text-field v-model.number="form.balance" label="Current Balance" type="number" prefix="$" />
|
||||||
|
<v-text-field v-if="showCreditLimit" v-model.number="form.creditLimit" label="Credit Limit" type="number" prefix="$" />
|
||||||
|
<v-text-field v-if="showInterestRate" v-model.number="form.interestRate" label="Interest Rate (APR)" type="number" suffix="%" />
|
||||||
|
<v-textarea v-model="form.notes" label="Notes" rows="2" />
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn @click="showDialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="primary" @click="saveAccount">{{ editing ? 'Update' : 'Create' }}</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<v-dialog v-model="showDeleteDialog" max-width="400">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Delete Account</v-card-title>
|
||||||
|
<v-card-text>Are you sure you want to delete "{{ deleteTarget?.name }}"? This will also delete all transactions.</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn @click="showDeleteDialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="error" @click="doDelete">Delete</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useAccountsStore } from '@/stores/accounts'
|
||||||
|
import { formatCurrency, accountTypeLabel, accountTypeIcon } from '@/utils/formatters'
|
||||||
|
import type { Account } from '@/types'
|
||||||
|
|
||||||
|
const accountsStore = useAccountsStore()
|
||||||
|
const showDialog = ref(false)
|
||||||
|
const showDeleteDialog = ref(false)
|
||||||
|
const editing = ref(false)
|
||||||
|
const editingId = ref('')
|
||||||
|
const deleteTarget = ref<Account | null>(null)
|
||||||
|
|
||||||
|
const accountTypes = [
|
||||||
|
{ label: 'Checking', value: 'Checking' }, { label: 'Savings', value: 'Savings' },
|
||||||
|
{ label: 'Credit Card', value: 'CreditCard' }, { label: 'Auto Loan', value: 'AutoLoan' },
|
||||||
|
{ label: 'Personal Loan', value: 'PersonalLoan' }, { label: 'Mortgage', value: 'Mortgage' },
|
||||||
|
{ label: 'Line of Credit', value: 'LineOfCredit' }, { label: '401(k)', value: 'Retirement401k' },
|
||||||
|
{ label: 'IRA', value: 'IRA' }, { label: 'Brokerage', value: 'Brokerage' }, { label: 'Cash', value: 'Cash' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const form = ref({ name: '', type: 'Checking', institution: '', accountNumber: '', balance: 0, creditLimit: null as number | null, interestRate: null as number | null, notes: '' })
|
||||||
|
|
||||||
|
const showCreditLimit = computed(() => ['CreditCard', 'LineOfCredit'].includes(form.value.type))
|
||||||
|
const showInterestRate = computed(() => ['CreditCard', 'AutoLoan', 'PersonalLoan', 'Mortgage', 'LineOfCredit'].includes(form.value.type))
|
||||||
|
|
||||||
|
onMounted(() => accountsStore.fetchAccounts())
|
||||||
|
|
||||||
|
function editAccount(account: Account) {
|
||||||
|
editing.value = true
|
||||||
|
editingId.value = account.id
|
||||||
|
form.value = { name: account.name, type: account.type, institution: account.institution || '', accountNumber: account.accountNumber || '', balance: account.balance, creditLimit: account.creditLimit, interestRate: account.interestRate, notes: account.notes || '' }
|
||||||
|
showDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(account: Account) {
|
||||||
|
deleteTarget.value = account
|
||||||
|
showDeleteDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAccount() {
|
||||||
|
if (editing.value) {
|
||||||
|
await accountsStore.updateAccount(editingId.value, { ...form.value, isActive: true, sortOrder: 0 })
|
||||||
|
} else {
|
||||||
|
await accountsStore.createAccount(form.value)
|
||||||
|
}
|
||||||
|
showDialog.value = false
|
||||||
|
editing.value = false
|
||||||
|
form.value = { name: '', type: 'Checking', institution: '', accountNumber: '', balance: 0, creditLimit: null, interestRate: null, notes: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doDelete() {
|
||||||
|
if (deleteTarget.value) {
|
||||||
|
await accountsStore.deleteAccount(deleteTarget.value.id)
|
||||||
|
showDeleteDialog.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<template>
|
||||||
|
<v-container class="fill-height" fluid>
|
||||||
|
<v-row align="center" justify="center">
|
||||||
|
<v-col cols="12" sm="8" md="4">
|
||||||
|
<v-card class="elevation-12">
|
||||||
|
<v-toolbar color="primary">
|
||||||
|
<v-toolbar-title><v-icon class="mr-2">mdi-cat</v-icon>Purrse Login</v-toolbar-title>
|
||||||
|
</v-toolbar>
|
||||||
|
<v-card-text>
|
||||||
|
<v-alert v-if="error" type="error" class="mb-4" closable>{{ error }}</v-alert>
|
||||||
|
<v-form @submit.prevent="handleLogin">
|
||||||
|
<v-text-field v-model="username" label="Username" prepend-icon="mdi-account" required autofocus />
|
||||||
|
<v-text-field v-model="password" label="Password" prepend-icon="mdi-lock" type="password" required />
|
||||||
|
<v-btn type="submit" color="primary" block size="large" :loading="loading" class="mt-4">Login</v-btn>
|
||||||
|
</v-form>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" to="/register">Create Account</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await authStore.login(username.value, password.value)
|
||||||
|
router.push('/')
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.response?.data?.error || 'Login failed'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<v-container class="fill-height" fluid>
|
||||||
|
<v-row align="center" justify="center">
|
||||||
|
<v-col cols="12" sm="8" md="4">
|
||||||
|
<v-card class="elevation-12">
|
||||||
|
<v-toolbar color="primary">
|
||||||
|
<v-toolbar-title><v-icon class="mr-2">mdi-cat</v-icon>Create Account</v-toolbar-title>
|
||||||
|
</v-toolbar>
|
||||||
|
<v-card-text>
|
||||||
|
<v-alert v-if="error" type="error" class="mb-4" closable>{{ error }}</v-alert>
|
||||||
|
<v-form @submit.prevent="handleRegister">
|
||||||
|
<v-text-field v-model="username" label="Username" prepend-icon="mdi-account" required />
|
||||||
|
<v-text-field v-model="email" label="Email" prepend-icon="mdi-email" type="email" required />
|
||||||
|
<v-text-field v-model="password" label="Password" prepend-icon="mdi-lock" type="password" required />
|
||||||
|
<v-text-field v-model="confirmPassword" label="Confirm Password" prepend-icon="mdi-lock-check" type="password" required />
|
||||||
|
<v-btn type="submit" color="primary" block size="large" :loading="loading" class="mt-4">Register</v-btn>
|
||||||
|
</v-form>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" to="/login">Already have an account?</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const username = ref('')
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const confirmPassword = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
async function handleRegister() {
|
||||||
|
if (password.value !== confirmPassword.value) {
|
||||||
|
error.value = 'Passwords do not match'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await authStore.register(username.value, email.value, password.value)
|
||||||
|
router.push('/')
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.response?.data?.error || 'Registration failed'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-h4 mb-4">Budgets</h1>
|
||||||
|
<v-card>
|
||||||
|
<v-card-text>
|
||||||
|
<v-row class="mb-4">
|
||||||
|
<v-col cols="auto">
|
||||||
|
<v-btn icon @click="prevMonth"><v-icon>mdi-chevron-left</v-icon></v-btn>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="auto"><h2>{{ monthLabel }}</h2></v-col>
|
||||||
|
<v-col cols="auto">
|
||||||
|
<v-btn icon @click="nextMonth"><v-icon>mdi-chevron-right</v-icon></v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<p class="text-medium-emphasis">Budget management coming in Phase 3. Set monthly spending targets per category and track actual vs budgeted amounts.</p>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
const year = ref(new Date().getFullYear())
|
||||||
|
const month = ref(new Date().getMonth() + 1)
|
||||||
|
const monthLabel = computed(() => new Date(year.value, month.value - 1).toLocaleDateString('en-US', { year: 'numeric', month: 'long' }))
|
||||||
|
function prevMonth() { if (month.value === 1) { month.value = 12; year.value-- } else month.value-- }
|
||||||
|
function nextMonth() { if (month.value === 12) { month.value = 1; year.value++ } else month.value++ }
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="d-flex align-center mb-4">
|
||||||
|
<h1 class="text-h4">Categories</h1>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-plus" @click="showDialog = true">Add Category</v-btn>
|
||||||
|
</div>
|
||||||
|
<v-card>
|
||||||
|
<v-card-text>
|
||||||
|
<v-treeview :items="categoryTree" item-value="id" item-title="name" open-all density="compact">
|
||||||
|
<template #prepend="{ item }">
|
||||||
|
<v-chip :color="item.type === 'Income' ? 'success' : item.type === 'Transfer' ? 'info' : 'error'" size="x-small">{{ item.type }}</v-chip>
|
||||||
|
</template>
|
||||||
|
</v-treeview>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-dialog v-model="showDialog" max-width="400">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Add Category</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-text-field v-model="form.name" label="Category Name" />
|
||||||
|
<v-select v-model="form.type" label="Type" :items="['Income','Expense','Transfer']" />
|
||||||
|
<v-select v-model="form.parentId" label="Parent Category" :items="parentOptions" item-title="name" item-value="id" clearable />
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn @click="showDialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="primary" @click="saveCategory">Create</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { categoriesApi } from '@/services/categories'
|
||||||
|
import type { CategoryTree, Category } from '@/types'
|
||||||
|
|
||||||
|
const categoryTree = ref<CategoryTree[]>([])
|
||||||
|
const allCategories = ref<Category[]>([])
|
||||||
|
const showDialog = ref(false)
|
||||||
|
const form = ref({ name: '', type: 'Expense', parentId: null as string | null })
|
||||||
|
|
||||||
|
const parentOptions = computed(() => allCategories.value.filter(c => !c.parentId))
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const [tree, all] = await Promise.all([categoriesApi.getTree(), categoriesApi.getAll()])
|
||||||
|
categoryTree.value = tree.data
|
||||||
|
allCategories.value = all.data
|
||||||
|
})
|
||||||
|
|
||||||
|
async function saveCategory() {
|
||||||
|
await categoriesApi.create(form.value)
|
||||||
|
const [tree, all] = await Promise.all([categoriesApi.getTree(), categoriesApi.getAll()])
|
||||||
|
categoryTree.value = tree.data
|
||||||
|
allCategories.value = all.data
|
||||||
|
showDialog.value = false
|
||||||
|
form.value = { name: '', type: 'Expense', parentId: null }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-h4 mb-4">Dashboard</h1>
|
||||||
|
<v-row v-if="loading">
|
||||||
|
<v-col cols="12" class="text-center"><v-progress-circular indeterminate color="primary" size="64" /></v-col>
|
||||||
|
</v-row>
|
||||||
|
<template v-else-if="dashboard">
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="4">
|
||||||
|
<v-card color="primary" variant="tonal">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="text-overline">Net Worth</div>
|
||||||
|
<div class="text-h4">{{ formatCurrency(dashboard.netWorth) }}</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="4">
|
||||||
|
<v-card color="success" variant="tonal">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="text-overline">Total Assets</div>
|
||||||
|
<div class="text-h4">{{ formatCurrency(dashboard.totalAssets) }}</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="4">
|
||||||
|
<v-card color="error" variant="tonal">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="text-overline">Total Liabilities</div>
|
||||||
|
<div class="text-h4">{{ formatCurrency(dashboard.totalLiabilities) }}</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row class="mt-4">
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title><v-icon class="mr-2">mdi-bank</v-icon>Accounts</v-card-title>
|
||||||
|
<v-list density="compact">
|
||||||
|
<v-list-item v-for="acct in dashboard.accounts" :key="acct.id" :to="`/accounts/${acct.id}/transactions`">
|
||||||
|
<template #prepend><v-icon>{{ accountTypeIcon(acct.type) }}</v-icon></template>
|
||||||
|
<v-list-item-title>{{ acct.name }}</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>{{ acct.institution }}</v-list-item-subtitle>
|
||||||
|
<template #append>
|
||||||
|
<span :class="acct.balance >= 0 ? 'text-success' : 'text-error'">{{ formatCurrency(acct.balance) }}</span>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title><v-icon class="mr-2">mdi-chart-pie</v-icon>Spending This Month</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<div class="text-h5 mb-2">{{ formatCurrency(dashboard.spendingSummary.thisMonth) }}</div>
|
||||||
|
<div class="text-caption" :class="dashboard.spendingSummary.changePercent <= 0 ? 'text-success' : 'text-error'">
|
||||||
|
{{ formatPercent(dashboard.spendingSummary.changePercent) }} vs last month
|
||||||
|
</div>
|
||||||
|
<v-list density="compact" class="mt-2">
|
||||||
|
<v-list-item v-for="cat in dashboard.spendingSummary.topCategories" :key="cat.categoryName">
|
||||||
|
<v-list-item-title>{{ cat.categoryName }}</v-list-item-title>
|
||||||
|
<template #append>{{ formatCurrency(cat.amount) }}</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row class="mt-4">
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title><v-icon class="mr-2">mdi-history</v-icon>Recent Transactions</v-card-title>
|
||||||
|
<v-list density="compact">
|
||||||
|
<v-list-item v-for="txn in dashboard.recentTransactions" :key="txn.id">
|
||||||
|
<v-list-item-title>{{ txn.payeeName || 'Unknown' }}</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>{{ formatDate(txn.date) }} - {{ txn.accountName }}</v-list-item-subtitle>
|
||||||
|
<template #append>
|
||||||
|
<span :class="txn.amount >= 0 ? 'text-success' : 'text-error'">{{ formatCurrency(txn.amount) }}</span>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title><v-icon class="mr-2">mdi-calendar-clock</v-icon>Upcoming Bills</v-card-title>
|
||||||
|
<v-list density="compact">
|
||||||
|
<v-list-item v-for="bill in dashboard.upcomingBills" :key="bill.id">
|
||||||
|
<v-list-item-title>{{ bill.payeeName }}</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>Due {{ formatDate(bill.dueDate) }} - {{ bill.accountName }}</v-list-item-subtitle>
|
||||||
|
<template #append>
|
||||||
|
<span class="text-error">{{ formatCurrency(bill.amount) }}</span>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item v-if="!dashboard.upcomingBills.length">
|
||||||
|
<v-list-item-title class="text-center text-medium-emphasis">No upcoming bills</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import api from '@/services/api'
|
||||||
|
import type { DashboardData } from '@/types'
|
||||||
|
import { formatCurrency, formatDate, formatPercent, accountTypeIcon } from '@/utils/formatters'
|
||||||
|
|
||||||
|
const dashboard = ref<DashboardData | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<DashboardData>('/dashboard')
|
||||||
|
dashboard.value = data
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-h4 mb-4">Investments</h1>
|
||||||
|
<v-card><v-card-text><p class="text-medium-emphasis">Investment portfolio tracking coming in Phase 5. Track 401k holdings, performance, and allocation.</p></v-card-text></v-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-h4 mb-4">Loan Details</h1>
|
||||||
|
<v-card><v-card-text><p class="text-medium-emphasis">Loan amortization schedules and payoff scenario calculator coming in Phase 3.</p></v-card-text></v-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ id?: string }>()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-h4 mb-4">Reports</h1>
|
||||||
|
<v-row>
|
||||||
|
<v-col v-for="report in reports" :key="report.title" cols="12" md="6">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title><v-icon class="mr-2">{{ report.icon }}</v-icon>{{ report.title }}</v-card-title>
|
||||||
|
<v-card-text>{{ report.description }}</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const reports = [
|
||||||
|
{ title: 'Spending by Category', icon: 'mdi-chart-pie', description: 'See where your money goes by category' },
|
||||||
|
{ title: 'Income vs Expense', icon: 'mdi-chart-bar', description: 'Monthly income compared to expenses' },
|
||||||
|
{ title: 'Net Worth', icon: 'mdi-chart-line', description: 'Track your net worth over time' },
|
||||||
|
{ title: 'Cash Flow', icon: 'mdi-chart-timeline-variant', description: 'Daily and monthly cash flow analysis' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-h4 mb-4">Scheduled Transactions</h1>
|
||||||
|
<v-card><v-card-text><p class="text-medium-emphasis">Recurring bill management coming in Phase 3. Set up automatic recurring transactions with reminders.</p></v-card-text></v-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="d-flex align-center mb-4">
|
||||||
|
<h1 class="text-h4">{{ accountName || 'Transactions' }}</h1>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn color="secondary" prepend-icon="mdi-swap-horizontal" class="mr-2" @click="showTransferDialog = true">Transfer</v-btn>
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-plus" @click="showAddDialog = true">Add Transaction</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-text>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="3">
|
||||||
|
<v-text-field v-model="search" label="Search" prepend-icon="mdi-magnify" clearable density="compact" @update:model-value="doSearch" />
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="3">
|
||||||
|
<v-text-field v-model="startDate" label="Start Date" type="date" density="compact" @update:model-value="doSearch" />
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="3">
|
||||||
|
<v-text-field v-model="endDate" label="End Date" type="date" density="compact" @update:model-value="doSearch" />
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="3">
|
||||||
|
<v-select v-model="statusFilter" label="Status" :items="['All','Uncleared','Cleared','Reconciled']" density="compact" @update:model-value="doSearch" />
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-card>
|
||||||
|
<v-data-table :headers="headers" :items="transactions" :loading="loading" :items-per-page="50" density="compact">
|
||||||
|
<template #item.date="{ item }">{{ formatDate(item.date) }}</template>
|
||||||
|
<template #item.amount="{ item }">
|
||||||
|
<span :class="item.amount >= 0 ? 'text-success' : 'text-error'">{{ formatCurrency(item.amount) }}</span>
|
||||||
|
</template>
|
||||||
|
<template #item.status="{ item }">
|
||||||
|
<v-chip :color="item.status === 'Reconciled' ? 'success' : item.status === 'Cleared' ? 'info' : 'default'" size="x-small">{{ item.status }}</v-chip>
|
||||||
|
</template>
|
||||||
|
<template #item.actions="{ item }">
|
||||||
|
<v-btn icon size="x-small" variant="text" @click="editTransaction(item)"><v-icon size="small">mdi-pencil</v-icon></v-btn>
|
||||||
|
<v-btn icon size="x-small" variant="text" color="error" @click="deleteTransaction(item.id)"><v-icon size="small">mdi-delete</v-icon></v-btn>
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<!-- Add/Edit Transaction Dialog -->
|
||||||
|
<v-dialog v-model="showAddDialog" max-width="600">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>{{ editingTxn ? 'Edit Transaction' : 'Add Transaction' }}</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-select v-if="!props.id" v-model="txnForm.accountId" label="Account" :items="accountsStore.accounts" item-title="name" item-value="id" />
|
||||||
|
<v-text-field v-model="txnForm.date" label="Date" type="date" />
|
||||||
|
<v-text-field v-model.number="txnForm.amount" label="Amount" type="number" prefix="$" />
|
||||||
|
<v-select v-model="txnForm.type" label="Type" :items="['Debit','Credit']" />
|
||||||
|
<v-text-field v-model="txnForm.payeeName" label="Payee" />
|
||||||
|
<v-text-field v-model="txnForm.memo" label="Memo" />
|
||||||
|
<v-text-field v-model="txnForm.checkNumber" label="Check #" />
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn @click="showAddDialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="primary" @click="saveTxn">Save</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- Transfer Dialog -->
|
||||||
|
<v-dialog v-model="showTransferDialog" max-width="500">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Transfer Between Accounts</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-select v-model="transferForm.fromAccountId" label="From Account" :items="accountsStore.accounts" item-title="name" item-value="id" />
|
||||||
|
<v-select v-model="transferForm.toAccountId" label="To Account" :items="accountsStore.accounts" item-title="name" item-value="id" />
|
||||||
|
<v-text-field v-model="transferForm.date" label="Date" type="date" />
|
||||||
|
<v-text-field v-model.number="transferForm.amount" label="Amount" type="number" prefix="$" />
|
||||||
|
<v-text-field v-model="transferForm.memo" label="Memo" />
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn @click="showTransferDialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="primary" @click="createTransfer">Transfer</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch } from 'vue'
|
||||||
|
import { transactionsApi } from '@/services/transactions'
|
||||||
|
import { useAccountsStore } from '@/stores/accounts'
|
||||||
|
import { formatCurrency, formatDate } from '@/utils/formatters'
|
||||||
|
import type { Transaction } from '@/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ id?: string }>()
|
||||||
|
const accountsStore = useAccountsStore()
|
||||||
|
const transactions = ref<Transaction[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const accountName = ref('')
|
||||||
|
const search = ref('')
|
||||||
|
const startDate = ref('')
|
||||||
|
const endDate = ref('')
|
||||||
|
const statusFilter = ref('All')
|
||||||
|
const showAddDialog = ref(false)
|
||||||
|
const showTransferDialog = ref(false)
|
||||||
|
const editingTxn = ref(false)
|
||||||
|
const editingTxnId = ref('')
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
{ title: 'Date', key: 'date', width: '100px' },
|
||||||
|
{ title: 'Payee', key: 'payeeName' },
|
||||||
|
{ title: 'Category', key: 'categoryName' },
|
||||||
|
{ title: 'Memo', key: 'memo' },
|
||||||
|
{ title: 'Amount', key: 'amount', align: 'end' as const },
|
||||||
|
{ title: 'Status', key: 'status', width: '100px' },
|
||||||
|
{ title: '', key: 'actions', sortable: false, width: '80px' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const txnForm = ref({ accountId: '', date: new Date().toISOString().split('T')[0], amount: 0, type: 'Debit', payeeName: '', memo: '', checkNumber: '' })
|
||||||
|
const transferForm = ref({ fromAccountId: '', toAccountId: '', date: new Date().toISOString().split('T')[0], amount: 0, memo: '' })
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await accountsStore.fetchAccounts()
|
||||||
|
if (props.id) {
|
||||||
|
txnForm.value.accountId = props.id
|
||||||
|
const acct = accountsStore.accounts.find(a => a.id === props.id)
|
||||||
|
accountName.value = acct?.name || ''
|
||||||
|
}
|
||||||
|
await loadTransactions()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.id, () => loadTransactions())
|
||||||
|
|
||||||
|
async function loadTransactions() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
if (props.id) {
|
||||||
|
const { data } = await transactionsApi.getByAccount(props.id)
|
||||||
|
transactions.value = data.items
|
||||||
|
} else {
|
||||||
|
const { data } = await transactionsApi.search({})
|
||||||
|
transactions.value = data.items
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSearch() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await transactionsApi.search({
|
||||||
|
accountId: props.id || undefined,
|
||||||
|
searchText: search.value || undefined,
|
||||||
|
startDate: startDate.value || undefined,
|
||||||
|
endDate: endDate.value || undefined,
|
||||||
|
status: statusFilter.value === 'All' ? undefined : statusFilter.value,
|
||||||
|
})
|
||||||
|
transactions.value = data.items
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function editTransaction(txn: Transaction) {
|
||||||
|
editingTxn.value = true
|
||||||
|
editingTxnId.value = txn.id
|
||||||
|
txnForm.value = { accountId: txn.accountId, date: txn.date.split('T')[0], amount: Math.abs(txn.amount), type: txn.type, payeeName: txn.payeeName || '', memo: txn.memo || '', checkNumber: txn.checkNumber || '' }
|
||||||
|
showAddDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTxn() {
|
||||||
|
const amount = txnForm.value.type === 'Debit' ? -Math.abs(txnForm.value.amount) : Math.abs(txnForm.value.amount)
|
||||||
|
if (editingTxn.value) {
|
||||||
|
await transactionsApi.update(editingTxnId.value, { ...txnForm.value, amount, status: 'Uncleared', splits: null })
|
||||||
|
} else {
|
||||||
|
await transactionsApi.create({ ...txnForm.value, amount, accountId: txnForm.value.accountId || props.id })
|
||||||
|
}
|
||||||
|
showAddDialog.value = false
|
||||||
|
editingTxn.value = false
|
||||||
|
await loadTransactions()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTransaction(id: string) {
|
||||||
|
await transactionsApi.delete(id)
|
||||||
|
await loadTransactions()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTransfer() {
|
||||||
|
await transactionsApi.createTransfer(transferForm.value)
|
||||||
|
showTransferDialog.value = false
|
||||||
|
await loadTransactions()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "env.d.ts"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:5000',
|
||||||
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
'/hubs': {
|
||||||
|
target: 'http://localhost:5000',
|
||||||
|
ws: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Purrse.Core.DTOs;
|
||||||
|
using Purrse.Core.Interfaces.Services;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class AccountsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IAccountService _accountService;
|
||||||
|
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
|
||||||
|
public AccountsController(IAccountService accountService) => _accountService = accountService;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<List<AccountResponse>>> GetAll()
|
||||||
|
=> Ok(await _accountService.GetAllAsync(UserId));
|
||||||
|
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public async Task<ActionResult<AccountResponse>> GetById(Guid id)
|
||||||
|
{
|
||||||
|
var account = await _accountService.GetByIdAsync(UserId, id);
|
||||||
|
return account == null ? NotFound() : Ok(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<ActionResult<AccountResponse>> Create(CreateAccountRequest request)
|
||||||
|
{
|
||||||
|
var account = await _accountService.CreateAsync(UserId, request);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = account.Id }, account);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id}")]
|
||||||
|
public async Task<ActionResult<AccountResponse>> Update(Guid id, UpdateAccountRequest request)
|
||||||
|
=> Ok(await _accountService.UpdateAsync(UserId, id, request));
|
||||||
|
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
public async Task<ActionResult> Delete(Guid id)
|
||||||
|
{
|
||||||
|
await _accountService.DeleteAsync(UserId, id);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}/balance-history")]
|
||||||
|
public async Task<ActionResult<List<BalanceHistoryEntry>>> GetBalanceHistory(Guid id, [FromQuery] DateTime startDate, [FromQuery] DateTime endDate)
|
||||||
|
=> Ok(await _accountService.GetBalanceHistoryAsync(UserId, id, startDate, endDate));
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Purrse.Core.DTOs;
|
||||||
|
using Purrse.Core.Interfaces.Services;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class AuthController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IAuthService _authService;
|
||||||
|
|
||||||
|
public AuthController(IAuthService authService) => _authService = authService;
|
||||||
|
|
||||||
|
[HttpPost("register")]
|
||||||
|
public async Task<ActionResult<AuthResponse>> Register(RegisterRequest request)
|
||||||
|
{
|
||||||
|
var result = await _authService.RegisterAsync(request);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("login")]
|
||||||
|
public async Task<ActionResult<AuthResponse>> Login(LoginRequest request)
|
||||||
|
{
|
||||||
|
var result = await _authService.LoginAsync(request);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("refresh")]
|
||||||
|
public async Task<ActionResult<AuthResponse>> Refresh(RefreshRequest request)
|
||||||
|
{
|
||||||
|
var result = await _authService.RefreshTokenAsync(request.RefreshToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[HttpPost("revoke")]
|
||||||
|
public async Task<ActionResult> Revoke()
|
||||||
|
{
|
||||||
|
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
await _authService.RevokeRefreshTokenAsync(userId);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Purrse.Core.DTOs;
|
||||||
|
using Purrse.Core.Interfaces.Services;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class BudgetsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IBudgetService _budgetService;
|
||||||
|
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
|
||||||
|
public BudgetsController(IBudgetService budgetService) => _budgetService = budgetService;
|
||||||
|
|
||||||
|
[HttpGet("{year}/{month}")]
|
||||||
|
public async Task<ActionResult<BudgetResponse>> GetByMonth(int year, int month)
|
||||||
|
{
|
||||||
|
var budget = await _budgetService.GetByMonthAsync(UserId, year, month);
|
||||||
|
return budget == null ? NotFound() : Ok(budget);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<ActionResult<BudgetResponse>> Create(CreateBudgetRequest request)
|
||||||
|
=> Ok(await _budgetService.CreateAsync(UserId, request));
|
||||||
|
|
||||||
|
[HttpPut("{id}")]
|
||||||
|
public async Task<ActionResult<BudgetResponse>> Update(Guid id, UpdateBudgetRequest request)
|
||||||
|
=> Ok(await _budgetService.UpdateAsync(UserId, id, request));
|
||||||
|
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
public async Task<ActionResult> Delete(Guid id)
|
||||||
|
{
|
||||||
|
await _budgetService.DeleteAsync(UserId, id);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}/actual")]
|
||||||
|
public async Task<ActionResult<List<BudgetItemResponse>>> GetActuals(Guid id)
|
||||||
|
=> Ok(await _budgetService.GetActualsAsync(UserId, id));
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Purrse.Core.DTOs;
|
||||||
|
using Purrse.Core.Interfaces.Services;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class CategoriesController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ICategoryService _categoryService;
|
||||||
|
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
|
||||||
|
public CategoriesController(ICategoryService categoryService) => _categoryService = categoryService;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<List<CategoryResponse>>> GetAll()
|
||||||
|
=> Ok(await _categoryService.GetAllAsync(UserId));
|
||||||
|
|
||||||
|
[HttpGet("tree")]
|
||||||
|
public async Task<ActionResult<List<CategoryTreeResponse>>> GetTree()
|
||||||
|
=> Ok(await _categoryService.GetTreeAsync(UserId));
|
||||||
|
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public async Task<ActionResult<CategoryResponse>> GetById(Guid id)
|
||||||
|
{
|
||||||
|
var cat = await _categoryService.GetByIdAsync(UserId, id);
|
||||||
|
return cat == null ? NotFound() : Ok(cat);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<ActionResult<CategoryResponse>> Create(CreateCategoryRequest request)
|
||||||
|
{
|
||||||
|
var cat = await _categoryService.CreateAsync(UserId, request);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = cat.Id }, cat);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id}")]
|
||||||
|
public async Task<ActionResult<CategoryResponse>> Update(Guid id, UpdateCategoryRequest request)
|
||||||
|
=> Ok(await _categoryService.UpdateAsync(UserId, id, request));
|
||||||
|
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
public async Task<ActionResult> Delete(Guid id)
|
||||||
|
{
|
||||||
|
await _categoryService.DeleteAsync(UserId, id);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Purrse.Core.DTOs;
|
||||||
|
using Purrse.Core.Interfaces.Services;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class DashboardController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IDashboardService _dashboardService;
|
||||||
|
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
|
||||||
|
public DashboardController(IDashboardService dashboardService) => _dashboardService = dashboardService;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<DashboardResponse>> Get()
|
||||||
|
=> Ok(await _dashboardService.GetDashboardAsync(UserId));
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Purrse.Core.DTOs;
|
||||||
|
using Purrse.Core.Interfaces.Services;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class ImportsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IImportService _importService;
|
||||||
|
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
|
||||||
|
public ImportsController(IImportService importService) => _importService = importService;
|
||||||
|
|
||||||
|
[HttpPost("upload")]
|
||||||
|
public async Task<ActionResult<ImportUploadResponse>> Upload([FromQuery] Guid accountId, IFormFile file)
|
||||||
|
{
|
||||||
|
if (file == null || file.Length == 0)
|
||||||
|
return BadRequest(new { error = "No file uploaded" });
|
||||||
|
|
||||||
|
using var stream = file.OpenReadStream();
|
||||||
|
var result = await _importService.UploadAsync(UserId, accountId, file.FileName, stream);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("batches/{batchId}/confirm")]
|
||||||
|
public async Task<ActionResult<ImportBatchResponse>> ConfirmBatch(Guid batchId)
|
||||||
|
=> Ok(await _importService.ConfirmBatchAsync(UserId, batchId));
|
||||||
|
|
||||||
|
[HttpPost("batches/{batchId}/resolve-duplicates")]
|
||||||
|
public async Task<ActionResult> ResolveDuplicates(Guid batchId, ResolveDuplicatesRequest request)
|
||||||
|
{
|
||||||
|
await _importService.ResolveDuplicatesAsync(UserId, batchId, request);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Purrse.Core.DTOs;
|
||||||
|
using Purrse.Data;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class InvestmentsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly PurrseDbContext _db;
|
||||||
|
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
|
||||||
|
public InvestmentsController(PurrseDbContext db) => _db = db;
|
||||||
|
|
||||||
|
[HttpGet("{accountId}/holdings")]
|
||||||
|
public async Task<ActionResult<List<InvestmentHoldingResponse>>> GetHoldings(Guid accountId)
|
||||||
|
{
|
||||||
|
var account = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == UserId)
|
||||||
|
?? throw new KeyNotFoundException("Account not found");
|
||||||
|
|
||||||
|
var holdings = await _db.InvestmentHoldings
|
||||||
|
.Include(h => h.Security).ThenInclude(s => s.Prices)
|
||||||
|
.Where(h => h.AccountId == accountId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var responses = holdings.Select(h =>
|
||||||
|
{
|
||||||
|
var latestPrice = h.Security.Prices.OrderByDescending(p => p.Date).FirstOrDefault()?.Price ?? 0;
|
||||||
|
var marketValue = h.Shares * latestPrice;
|
||||||
|
var gainLoss = marketValue - h.CostBasis;
|
||||||
|
var gainLossPct = h.CostBasis != 0 ? (gainLoss / h.CostBasis) * 100 : 0;
|
||||||
|
|
||||||
|
return new InvestmentHoldingResponse(h.Id, h.Security.Symbol, h.Security.Name,
|
||||||
|
h.Shares, h.CostBasis, latestPrice, marketValue, gainLoss, gainLossPct, h.AsOfDate);
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return Ok(responses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{accountId}/performance")]
|
||||||
|
public async Task<ActionResult<InvestmentPerformanceResponse>> GetPerformance(Guid accountId)
|
||||||
|
{
|
||||||
|
var holdingsResult = await GetHoldings(accountId);
|
||||||
|
var holdings = (holdingsResult.Result as OkObjectResult)?.Value as List<InvestmentHoldingResponse> ?? new();
|
||||||
|
|
||||||
|
var totalMarket = holdings.Sum(h => h.MarketValue);
|
||||||
|
var totalCost = holdings.Sum(h => h.CostBasis);
|
||||||
|
var totalGL = totalMarket - totalCost;
|
||||||
|
var totalPct = totalCost != 0 ? (totalGL / totalCost) * 100 : 0;
|
||||||
|
|
||||||
|
return Ok(new InvestmentPerformanceResponse(totalMarket, totalCost, totalGL, totalPct, holdings));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Purrse.Core.DTOs;
|
||||||
|
using Purrse.Core.Interfaces.Services;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class LoansController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILoanService _loanService;
|
||||||
|
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
|
||||||
|
public LoansController(ILoanService loanService) => _loanService = loanService;
|
||||||
|
|
||||||
|
[HttpGet("{accountId}/amortization")]
|
||||||
|
public async Task<ActionResult<LoanDetailResponse>> GetLoanDetail(Guid accountId)
|
||||||
|
{
|
||||||
|
var detail = await _loanService.GetLoanDetailAsync(UserId, accountId);
|
||||||
|
return detail == null ? NotFound() : Ok(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{accountId}")]
|
||||||
|
public async Task<ActionResult<LoanDetailResponse>> CreateLoanDetail(Guid accountId, CreateLoanDetailRequest request)
|
||||||
|
=> Ok(await _loanService.CreateLoanDetailAsync(UserId, accountId, request));
|
||||||
|
|
||||||
|
[HttpPut("{accountId}")]
|
||||||
|
public async Task<ActionResult<LoanDetailResponse>> UpdateLoanDetail(Guid accountId, CreateLoanDetailRequest request)
|
||||||
|
=> Ok(await _loanService.UpdateLoanDetailAsync(UserId, accountId, request));
|
||||||
|
|
||||||
|
[HttpPost("{accountId}/payoff-scenario")]
|
||||||
|
public async Task<ActionResult<PayoffScenarioResponse>> PayoffScenario(Guid accountId, PayoffScenarioRequest request)
|
||||||
|
=> Ok(await _loanService.CalculatePayoffScenarioAsync(UserId, accountId, request));
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Purrse.Core.DTOs;
|
||||||
|
using Purrse.Core.Interfaces.Services;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class PayeesController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IPayeeService _payeeService;
|
||||||
|
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
|
||||||
|
public PayeesController(IPayeeService payeeService) => _payeeService = payeeService;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<List<PayeeResponse>>> GetAll()
|
||||||
|
=> Ok(await _payeeService.GetAllAsync(UserId));
|
||||||
|
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public async Task<ActionResult<PayeeResponse>> GetById(Guid id)
|
||||||
|
{
|
||||||
|
var payee = await _payeeService.GetByIdAsync(UserId, id);
|
||||||
|
return payee == null ? NotFound() : Ok(payee);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<ActionResult<PayeeResponse>> Create(CreatePayeeRequest request)
|
||||||
|
{
|
||||||
|
var payee = await _payeeService.CreateAsync(UserId, request);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = payee.Id }, payee);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id}")]
|
||||||
|
public async Task<ActionResult<PayeeResponse>> Update(Guid id, UpdatePayeeRequest request)
|
||||||
|
=> Ok(await _payeeService.UpdateAsync(UserId, id, request));
|
||||||
|
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
public async Task<ActionResult> Delete(Guid id)
|
||||||
|
{
|
||||||
|
await _payeeService.DeleteAsync(UserId, id);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id}/aliases")]
|
||||||
|
public async Task<ActionResult> AddAlias(Guid id, CreatePayeeAliasRequest request)
|
||||||
|
{
|
||||||
|
await _payeeService.AddAliasAsync(UserId, id, request.Alias);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id}/aliases/{aliasId}")]
|
||||||
|
public async Task<ActionResult> RemoveAlias(Guid id, Guid aliasId)
|
||||||
|
{
|
||||||
|
await _payeeService.RemoveAliasAsync(UserId, id, aliasId);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Purrse.Api.Services;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class PluginsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly PluginService _pluginService;
|
||||||
|
|
||||||
|
public PluginsController(PluginService pluginService) => _pluginService = pluginService;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public ActionResult GetAll()
|
||||||
|
{
|
||||||
|
var plugins = _pluginService.GetPlugins().Select(p => new
|
||||||
|
{
|
||||||
|
p.Id, p.Name, p.Version, p.Description
|
||||||
|
});
|
||||||
|
return Ok(plugins);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Purrse.Core.DTOs;
|
||||||
|
using Purrse.Core.Enums;
|
||||||
|
using Purrse.Core.Models;
|
||||||
|
using Purrse.Data;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class ReconciliationController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly PurrseDbContext _db;
|
||||||
|
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
|
||||||
|
public ReconciliationController(PurrseDbContext db) => _db = db;
|
||||||
|
|
||||||
|
[HttpPost("{accountId}/start")]
|
||||||
|
public async Task<ActionResult<ReconciliationResponse>> Start(Guid accountId, StartReconciliationRequest request)
|
||||||
|
{
|
||||||
|
var account = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == UserId)
|
||||||
|
?? throw new KeyNotFoundException("Account not found");
|
||||||
|
|
||||||
|
var reconciliation = new Reconciliation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
AccountId = accountId,
|
||||||
|
StatementDate = request.StatementDate,
|
||||||
|
StatementBalance = request.StatementBalance
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.Reconciliations.Add(reconciliation);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var uncleared = await _db.Transactions
|
||||||
|
.Include(t => t.Category)
|
||||||
|
.Where(t => t.AccountId == accountId && t.Status != TransactionStatus.Reconciled && !t.IsVoid)
|
||||||
|
.OrderByDescending(t => t.Date)
|
||||||
|
.Select(t => new TransactionResponse(t.Id, t.AccountId, null, t.Date, t.Amount,
|
||||||
|
t.PayeeId, t.PayeeName, t.CategoryId, t.Category != null ? t.Category.Name : null,
|
||||||
|
t.Memo, t.ReferenceNumber, t.CheckNumber, t.Type, t.Status, t.IsVoid,
|
||||||
|
t.TransferTransactionId, t.ImportBatchId, null, t.CreatedAt))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var clearedBalance = await _db.Transactions
|
||||||
|
.Where(t => t.AccountId == accountId && t.Status == TransactionStatus.Cleared && !t.IsVoid)
|
||||||
|
.SumAsync(t => t.Amount);
|
||||||
|
|
||||||
|
return Ok(new ReconciliationResponse(reconciliation.Id, accountId, request.StatementDate,
|
||||||
|
request.StatementBalance, clearedBalance, request.StatementBalance - clearedBalance, false, uncleared));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{accountId}/complete")]
|
||||||
|
public async Task<ActionResult> Complete(Guid accountId, [FromQuery] Guid reconciliationId)
|
||||||
|
{
|
||||||
|
var rec = await _db.Reconciliations.FirstOrDefaultAsync(r => r.Id == reconciliationId && r.AccountId == accountId)
|
||||||
|
?? throw new KeyNotFoundException("Reconciliation not found");
|
||||||
|
|
||||||
|
var clearedTxns = await _db.Transactions
|
||||||
|
.Where(t => t.AccountId == accountId && t.Status == TransactionStatus.Cleared && !t.IsVoid)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var txn in clearedTxns)
|
||||||
|
{
|
||||||
|
txn.Status = TransactionStatus.Reconciled;
|
||||||
|
txn.ReconciliationId = reconciliationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
rec.IsCompleted = true;
|
||||||
|
rec.CompletedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{accountId}/cancel")]
|
||||||
|
public async Task<ActionResult> Cancel(Guid accountId, [FromQuery] Guid reconciliationId)
|
||||||
|
{
|
||||||
|
var rec = await _db.Reconciliations.FirstOrDefaultAsync(r => r.Id == reconciliationId && r.AccountId == accountId && !r.IsCompleted)
|
||||||
|
?? throw new KeyNotFoundException("Reconciliation not found");
|
||||||
|
|
||||||
|
_db.Reconciliations.Remove(rec);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Purrse.Core.DTOs;
|
||||||
|
using Purrse.Core.Interfaces.Services;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class ReportsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IReportService _reportService;
|
||||||
|
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
|
||||||
|
public ReportsController(IReportService reportService) => _reportService = reportService;
|
||||||
|
|
||||||
|
[HttpGet("spending-by-category")]
|
||||||
|
public async Task<ActionResult<SpendingByCategoryReport>> SpendingByCategory([FromQuery] DateTime startDate, [FromQuery] DateTime endDate, [FromQuery] Guid? accountId)
|
||||||
|
=> Ok(await _reportService.GetSpendingByCategoryAsync(UserId, startDate, endDate, accountId));
|
||||||
|
|
||||||
|
[HttpGet("income-vs-expense")]
|
||||||
|
public async Task<ActionResult<IncomeVsExpenseReport>> IncomeVsExpense([FromQuery] DateTime startDate, [FromQuery] DateTime endDate)
|
||||||
|
=> Ok(await _reportService.GetIncomeVsExpenseAsync(UserId, startDate, endDate));
|
||||||
|
|
||||||
|
[HttpGet("net-worth")]
|
||||||
|
public async Task<ActionResult<NetWorthReport>> NetWorth([FromQuery] DateTime startDate, [FromQuery] DateTime endDate)
|
||||||
|
=> Ok(await _reportService.GetNetWorthAsync(UserId, startDate, endDate));
|
||||||
|
|
||||||
|
[HttpGet("cash-flow")]
|
||||||
|
public async Task<ActionResult<CashFlowReport>> CashFlow([FromQuery] DateTime startDate, [FromQuery] DateTime endDate)
|
||||||
|
=> Ok(await _reportService.GetCashFlowAsync(UserId, startDate, endDate));
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Purrse.Core.DTOs;
|
||||||
|
using Purrse.Core.Interfaces.Services;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/scheduled-transactions")]
|
||||||
|
public class ScheduledTransactionsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IScheduledTransactionService _service;
|
||||||
|
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
|
||||||
|
public ScheduledTransactionsController(IScheduledTransactionService service) => _service = service;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<List<ScheduledTransactionResponse>>> GetAll()
|
||||||
|
=> Ok(await _service.GetAllAsync(UserId));
|
||||||
|
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public async Task<ActionResult<ScheduledTransactionResponse>> GetById(Guid id)
|
||||||
|
{
|
||||||
|
var s = await _service.GetByIdAsync(UserId, id);
|
||||||
|
return s == null ? NotFound() : Ok(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<ActionResult<ScheduledTransactionResponse>> Create(CreateScheduledTransactionRequest request)
|
||||||
|
=> Ok(await _service.CreateAsync(UserId, request));
|
||||||
|
|
||||||
|
[HttpPut("{id}")]
|
||||||
|
public async Task<ActionResult<ScheduledTransactionResponse>> Update(Guid id, UpdateScheduledTransactionRequest request)
|
||||||
|
=> Ok(await _service.UpdateAsync(UserId, id, request));
|
||||||
|
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
public async Task<ActionResult> Delete(Guid id)
|
||||||
|
{
|
||||||
|
await _service.DeleteAsync(UserId, id);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id}/skip")]
|
||||||
|
public async Task<ActionResult> Skip(Guid id)
|
||||||
|
{
|
||||||
|
await _service.SkipAsync(UserId, id);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id}/post-now")]
|
||||||
|
public async Task<ActionResult<TransactionResponse>> PostNow(Guid id)
|
||||||
|
=> Ok(await _service.PostNowAsync(UserId, id));
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Purrse.Core.DTOs;
|
||||||
|
using Purrse.Core.Interfaces.Services;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class TransactionsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ITransactionService _transactionService;
|
||||||
|
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
|
||||||
|
public TransactionsController(ITransactionService transactionService) => _transactionService = transactionService;
|
||||||
|
|
||||||
|
[HttpGet("account/{accountId}")]
|
||||||
|
public async Task<ActionResult<PagedResult<TransactionResponse>>> GetByAccount(Guid accountId, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
|
||||||
|
=> Ok(await _transactionService.GetByAccountAsync(UserId, accountId, page, pageSize));
|
||||||
|
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public async Task<ActionResult<TransactionResponse>> GetById(Guid id)
|
||||||
|
{
|
||||||
|
var txn = await _transactionService.GetByIdAsync(UserId, id);
|
||||||
|
return txn == null ? NotFound() : Ok(txn);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<ActionResult<TransactionResponse>> Create(CreateTransactionRequest request)
|
||||||
|
{
|
||||||
|
var txn = await _transactionService.CreateAsync(UserId, request);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = txn.Id }, txn);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id}")]
|
||||||
|
public async Task<ActionResult<TransactionResponse>> Update(Guid id, UpdateTransactionRequest request)
|
||||||
|
=> Ok(await _transactionService.UpdateAsync(UserId, id, request));
|
||||||
|
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
public async Task<ActionResult> Delete(Guid id)
|
||||||
|
{
|
||||||
|
await _transactionService.DeleteAsync(UserId, id);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("search")]
|
||||||
|
public async Task<ActionResult<PagedResult<TransactionResponse>>> Search(TransactionSearchRequest request)
|
||||||
|
=> Ok(await _transactionService.SearchAsync(UserId, request));
|
||||||
|
|
||||||
|
[HttpPost("transfer")]
|
||||||
|
public async Task<ActionResult> CreateTransfer(CreateTransferRequest request)
|
||||||
|
{
|
||||||
|
var (from, to) = await _transactionService.CreateTransferAsync(UserId, request);
|
||||||
|
return Ok(new { from, to });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id}/void")]
|
||||||
|
public async Task<ActionResult> Void(Guid id)
|
||||||
|
{
|
||||||
|
await _transactionService.VoidAsync(UserId, id);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
|
||||||
|
WORKDIR /app
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY ["src/Purrse.Core/Purrse.Core.csproj", "Purrse.Core/"]
|
||||||
|
COPY ["src/Purrse.Data/Purrse.Data.csproj", "Purrse.Data/"]
|
||||||
|
COPY ["src/Purrse.Api/Purrse.Api.csproj", "Purrse.Api/"]
|
||||||
|
COPY ["src/Purrse.Plugins.Abstractions/Purrse.Plugins.Abstractions.csproj", "Purrse.Plugins.Abstractions/"]
|
||||||
|
COPY ["src/Purrse.Plugins.OFX/Purrse.Plugins.OFX.csproj", "Purrse.Plugins.OFX/"]
|
||||||
|
COPY ["src/Purrse.Plugins.CSV/Purrse.Plugins.CSV.csproj", "Purrse.Plugins.CSV/"]
|
||||||
|
COPY ["src/Purrse.Plugins.QIF/Purrse.Plugins.QIF.csproj", "Purrse.Plugins.QIF/"]
|
||||||
|
RUN dotnet restore "Purrse.Api/Purrse.Api.csproj"
|
||||||
|
COPY src/ .
|
||||||
|
RUN dotnet publish "Purrse.Api/Purrse.Api.csproj" -c Release -o /app/publish
|
||||||
|
|
||||||
|
FROM base AS final
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/publish .
|
||||||
|
RUN mkdir -p /app/plugins /app/imports
|
||||||
|
ENTRYPOINT ["dotnet", "Purrse.Api.dll"]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Hubs;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
public class NotificationHub : Hub
|
||||||
|
{
|
||||||
|
public override async Task OnConnectedAsync()
|
||||||
|
{
|
||||||
|
var userId = Context.UserIdentifier;
|
||||||
|
if (userId != null)
|
||||||
|
await Groups.AddToGroupAsync(Context.ConnectionId, userId);
|
||||||
|
await base.OnConnectedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||||
|
{
|
||||||
|
var userId = Context.UserIdentifier;
|
||||||
|
if (userId != null)
|
||||||
|
await Groups.RemoveFromGroupAsync(Context.ConnectionId, userId);
|
||||||
|
await base.OnDisconnectedAsync(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Middleware;
|
||||||
|
|
||||||
|
public class ExceptionHandlingMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
|
||||||
|
|
||||||
|
public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _next(context);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await HandleExceptionAsync(context, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
|
||||||
|
{
|
||||||
|
var (statusCode, message) = exception switch
|
||||||
|
{
|
||||||
|
UnauthorizedAccessException => (HttpStatusCode.Unauthorized, exception.Message),
|
||||||
|
KeyNotFoundException => (HttpStatusCode.NotFound, exception.Message),
|
||||||
|
InvalidOperationException => (HttpStatusCode.BadRequest, exception.Message),
|
||||||
|
ArgumentException => (HttpStatusCode.BadRequest, exception.Message),
|
||||||
|
_ => (HttpStatusCode.InternalServerError, "An unexpected error occurred")
|
||||||
|
};
|
||||||
|
|
||||||
|
if (statusCode == HttpStatusCode.InternalServerError)
|
||||||
|
_logger.LogError(exception, "Unhandled exception");
|
||||||
|
else
|
||||||
|
_logger.LogWarning("Handled exception: {Message}", exception.Message);
|
||||||
|
|
||||||
|
context.Response.StatusCode = (int)statusCode;
|
||||||
|
context.Response.ContentType = "application/json";
|
||||||
|
|
||||||
|
var response = JsonSerializer.Serialize(new { error = message, statusCode = (int)statusCode });
|
||||||
|
await context.Response.WriteAsync(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Middleware;
|
||||||
|
|
||||||
|
public class RequestLoggingMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly ILogger<RequestLoggingMiddleware> _logger;
|
||||||
|
|
||||||
|
public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
await _next(context);
|
||||||
|
sw.Stop();
|
||||||
|
|
||||||
|
_logger.LogInformation("{Method} {Path} responded {StatusCode} in {Elapsed}ms",
|
||||||
|
context.Request.Method, context.Request.Path, context.Response.StatusCode, sw.ElapsedMilliseconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using Purrse.Api.Hubs;
|
||||||
|
using Purrse.Api.Middleware;
|
||||||
|
using Purrse.Api.Services;
|
||||||
|
using Purrse.Core.Interfaces.Services;
|
||||||
|
using Purrse.Data;
|
||||||
|
using Purrse.Plugins.Abstractions;
|
||||||
|
using Purrse.Plugins.CSV;
|
||||||
|
using Purrse.Plugins.OFX;
|
||||||
|
using Purrse.Plugins.QIF;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Database
|
||||||
|
builder.Services.AddDbContext<PurrseDbContext>(options =>
|
||||||
|
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||||
|
|
||||||
|
// JWT Authentication
|
||||||
|
var jwtKey = builder.Configuration["Jwt:Key"] ?? "PurrseDefaultSecretKey_ChangeInProduction_AtLeast32Chars!";
|
||||||
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
.AddJwtBearer(options =>
|
||||||
|
{
|
||||||
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
ValidIssuer = builder.Configuration["Jwt:Issuer"] ?? "Purrse",
|
||||||
|
ValidAudience = builder.Configuration["Jwt:Audience"] ?? "Purrse",
|
||||||
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey))
|
||||||
|
};
|
||||||
|
|
||||||
|
// SignalR JWT support
|
||||||
|
options.Events = new JwtBearerEvents
|
||||||
|
{
|
||||||
|
OnMessageReceived = context =>
|
||||||
|
{
|
||||||
|
var accessToken = context.Request.Query["access_token"];
|
||||||
|
var path = context.HttpContext.Request.Path;
|
||||||
|
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
|
||||||
|
{
|
||||||
|
context.Token = accessToken;
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// CORS
|
||||||
|
builder.Services.AddCors(options =>
|
||||||
|
{
|
||||||
|
options.AddDefaultPolicy(policy =>
|
||||||
|
{
|
||||||
|
policy.WithOrigins(
|
||||||
|
builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? new[] { "http://localhost:5173", "http://localhost:8080" })
|
||||||
|
.AllowAnyHeader()
|
||||||
|
.AllowAnyMethod()
|
||||||
|
.AllowCredentials();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Services
|
||||||
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||||
|
builder.Services.AddScoped<IAccountService, AccountService>();
|
||||||
|
builder.Services.AddScoped<ITransactionService, TransactionService>();
|
||||||
|
builder.Services.AddScoped<ICategoryService, CategoryService>();
|
||||||
|
builder.Services.AddScoped<IPayeeService, PayeeService>();
|
||||||
|
builder.Services.AddScoped<IBudgetService, BudgetService>();
|
||||||
|
builder.Services.AddScoped<IScheduledTransactionService, ScheduledTransactionService>();
|
||||||
|
builder.Services.AddScoped<IImportService, ImportService>();
|
||||||
|
builder.Services.AddScoped<ILoanService, LoanService>();
|
||||||
|
builder.Services.AddScoped<IDashboardService, DashboardService>();
|
||||||
|
builder.Services.AddScoped<IReportService, ReportService>();
|
||||||
|
builder.Services.AddScoped<IDuplicateDetectionService, DuplicateDetectionService>();
|
||||||
|
|
||||||
|
// Built-in file parsers
|
||||||
|
builder.Services.AddSingleton<IFileParser, OFXParserPlugin>();
|
||||||
|
builder.Services.AddSingleton<IFileParser, CSVParserPlugin>();
|
||||||
|
builder.Services.AddSingleton<IFileParser, QIFParserPlugin>();
|
||||||
|
|
||||||
|
// Plugin system
|
||||||
|
builder.Services.AddSingleton<PluginService>();
|
||||||
|
|
||||||
|
// Background services
|
||||||
|
builder.Services.AddHostedService<ScheduledTransactionProcessor>();
|
||||||
|
builder.Services.AddHostedService<FileWatcherService>();
|
||||||
|
|
||||||
|
// SignalR
|
||||||
|
builder.Services.AddSignalR();
|
||||||
|
|
||||||
|
builder.Services.AddControllers();
|
||||||
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Auto-migrate database
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<PurrseDbContext>();
|
||||||
|
await db.Database.MigrateAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize plugin system
|
||||||
|
var pluginService = app.Services.GetRequiredService<PluginService>();
|
||||||
|
await pluginService.DiscoverAndLoadPluginsAsync();
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.UseMiddleware<RequestLoggingMiddleware>();
|
||||||
|
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
||||||
|
|
||||||
|
app.UseCors();
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
app.MapControllers();
|
||||||
|
app.MapHub<NotificationHub>("/hubs/notifications");
|
||||||
|
|
||||||
|
app.Run();
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "http://localhost:5222",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.6" />
|
||||||
|
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Purrse.Core\Purrse.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\Purrse.Data\Purrse.Data.csproj" />
|
||||||
|
<ProjectReference Include="..\Purrse.Plugins.Abstractions\Purrse.Plugins.Abstractions.csproj" />
|
||||||
|
<ProjectReference Include="..\Purrse.Plugins.OFX\Purrse.Plugins.OFX.csproj" />
|
||||||
|
<ProjectReference Include="..\Purrse.Plugins.CSV\Purrse.Plugins.CSV.csproj" />
|
||||||
|
<ProjectReference Include="..\Purrse.Plugins.QIF\Purrse.Plugins.QIF.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Purrse.Core.DTOs;
|
||||||
|
using Purrse.Core.Interfaces.Services;
|
||||||
|
using Purrse.Core.Models;
|
||||||
|
using Purrse.Data;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Services;
|
||||||
|
|
||||||
|
public class AccountService : IAccountService
|
||||||
|
{
|
||||||
|
private readonly PurrseDbContext _db;
|
||||||
|
|
||||||
|
public AccountService(PurrseDbContext db)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<AccountResponse>> GetAllAsync(Guid userId)
|
||||||
|
{
|
||||||
|
return await _db.Accounts
|
||||||
|
.Where(a => a.UserId == userId)
|
||||||
|
.OrderBy(a => a.SortOrder).ThenBy(a => a.Name)
|
||||||
|
.Select(a => MapToResponse(a))
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AccountResponse?> GetByIdAsync(Guid userId, Guid accountId)
|
||||||
|
{
|
||||||
|
var account = await _db.Accounts
|
||||||
|
.Include(a => a.LoanDetail)
|
||||||
|
.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == userId);
|
||||||
|
return account == null ? null : MapToResponse(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AccountResponse> CreateAsync(Guid userId, CreateAccountRequest request)
|
||||||
|
{
|
||||||
|
var account = new Account
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
Name = request.Name,
|
||||||
|
Type = request.Type,
|
||||||
|
Institution = request.Institution,
|
||||||
|
AccountNumber = request.AccountNumber,
|
||||||
|
Balance = request.Balance,
|
||||||
|
CreditLimit = request.CreditLimit,
|
||||||
|
InterestRate = request.InterestRate,
|
||||||
|
Notes = request.Notes,
|
||||||
|
SortOrder = await _db.Accounts.CountAsync(a => a.UserId == userId)
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.Accounts.Add(account);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return MapToResponse(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AccountResponse> UpdateAsync(Guid userId, Guid accountId, UpdateAccountRequest request)
|
||||||
|
{
|
||||||
|
var account = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Account not found");
|
||||||
|
|
||||||
|
account.Name = request.Name;
|
||||||
|
account.Institution = request.Institution;
|
||||||
|
account.AccountNumber = request.AccountNumber;
|
||||||
|
account.CreditLimit = request.CreditLimit;
|
||||||
|
account.InterestRate = request.InterestRate;
|
||||||
|
account.Notes = request.Notes;
|
||||||
|
account.IsActive = request.IsActive;
|
||||||
|
account.SortOrder = request.SortOrder;
|
||||||
|
account.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return MapToResponse(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(Guid userId, Guid accountId)
|
||||||
|
{
|
||||||
|
var account = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Account not found");
|
||||||
|
|
||||||
|
_db.Accounts.Remove(account);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<BalanceHistoryEntry>> GetBalanceHistoryAsync(Guid userId, Guid accountId, DateTime startDate, DateTime endDate)
|
||||||
|
{
|
||||||
|
var account = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Account not found");
|
||||||
|
|
||||||
|
var transactions = await _db.Transactions
|
||||||
|
.Where(t => t.AccountId == accountId && t.Date >= startDate && t.Date <= endDate && !t.IsVoid)
|
||||||
|
.OrderBy(t => t.Date)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var balanceBefore = account.Balance - await _db.Transactions
|
||||||
|
.Where(t => t.AccountId == accountId && !t.IsVoid)
|
||||||
|
.SumAsync(t => t.Amount);
|
||||||
|
|
||||||
|
var priorSum = await _db.Transactions
|
||||||
|
.Where(t => t.AccountId == accountId && t.Date < startDate && !t.IsVoid)
|
||||||
|
.SumAsync(t => t.Amount);
|
||||||
|
|
||||||
|
var runningBalance = balanceBefore + priorSum;
|
||||||
|
var entries = new List<BalanceHistoryEntry>();
|
||||||
|
|
||||||
|
foreach (var group in transactions.GroupBy(t => t.Date.Date))
|
||||||
|
{
|
||||||
|
runningBalance += group.Sum(t => t.Amount);
|
||||||
|
entries.Add(new BalanceHistoryEntry(group.Key, runningBalance));
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RecalculateBalanceAsync(Guid accountId)
|
||||||
|
{
|
||||||
|
var account = await _db.Accounts.FindAsync(accountId);
|
||||||
|
if (account == null) return;
|
||||||
|
|
||||||
|
var sum = await _db.Transactions
|
||||||
|
.Where(t => t.AccountId == accountId && !t.IsVoid)
|
||||||
|
.SumAsync(t => t.Amount);
|
||||||
|
|
||||||
|
account.Balance = sum;
|
||||||
|
account.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AccountResponse MapToResponse(Account a) => new(
|
||||||
|
a.Id, a.Name, a.Type, a.Institution, a.AccountNumber,
|
||||||
|
a.Balance, a.CreditLimit, a.InterestRate,
|
||||||
|
a.IsActive, a.IsClosed, a.Notes, a.SortOrder, a.CreatedAt,
|
||||||
|
a.LoanDetail != null
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using Purrse.Core.DTOs;
|
||||||
|
using Purrse.Core.Interfaces.Services;
|
||||||
|
using Purrse.Core.Models;
|
||||||
|
using Purrse.Data;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Services;
|
||||||
|
|
||||||
|
public class AuthService : IAuthService
|
||||||
|
{
|
||||||
|
private readonly PurrseDbContext _db;
|
||||||
|
private readonly IConfiguration _config;
|
||||||
|
private readonly ICategoryService _categoryService;
|
||||||
|
|
||||||
|
public AuthService(PurrseDbContext db, IConfiguration config, ICategoryService categoryService)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_config = config;
|
||||||
|
_categoryService = categoryService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AuthResponse> RegisterAsync(RegisterRequest request)
|
||||||
|
{
|
||||||
|
if (await _db.Users.AnyAsync(u => u.Username == request.Username))
|
||||||
|
throw new InvalidOperationException("Username already exists");
|
||||||
|
if (await _db.Users.AnyAsync(u => u.Email == request.Email))
|
||||||
|
throw new InvalidOperationException("Email already exists");
|
||||||
|
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Username = request.Username,
|
||||||
|
Email = request.Email,
|
||||||
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.Users.Add(user);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await _categoryService.SeedDefaultCategoriesAsync(user.Id);
|
||||||
|
|
||||||
|
return GenerateAuthResponse(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AuthResponse> LoginAsync(LoginRequest request)
|
||||||
|
{
|
||||||
|
var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == request.Username)
|
||||||
|
?? throw new UnauthorizedAccessException("Invalid credentials");
|
||||||
|
|
||||||
|
if (!BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
|
||||||
|
throw new UnauthorizedAccessException("Invalid credentials");
|
||||||
|
|
||||||
|
user.LastLoginAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return GenerateAuthResponse(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AuthResponse> RefreshTokenAsync(string refreshToken)
|
||||||
|
{
|
||||||
|
var user = await _db.Users.FirstOrDefaultAsync(u =>
|
||||||
|
u.RefreshToken == refreshToken && u.RefreshTokenExpiresAt > DateTime.UtcNow)
|
||||||
|
?? throw new UnauthorizedAccessException("Invalid or expired refresh token");
|
||||||
|
|
||||||
|
return GenerateAuthResponse(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RevokeRefreshTokenAsync(Guid userId)
|
||||||
|
{
|
||||||
|
var user = await _db.Users.FindAsync(userId);
|
||||||
|
if (user != null)
|
||||||
|
{
|
||||||
|
user.RefreshToken = null;
|
||||||
|
user.RefreshTokenExpiresAt = null;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private AuthResponse GenerateAuthResponse(User user)
|
||||||
|
{
|
||||||
|
var expiresAt = DateTime.UtcNow.AddHours(12);
|
||||||
|
var token = GenerateJwtToken(user, expiresAt);
|
||||||
|
var refreshToken = GenerateRefreshToken();
|
||||||
|
|
||||||
|
user.RefreshToken = refreshToken;
|
||||||
|
user.RefreshTokenExpiresAt = DateTime.UtcNow.AddDays(30);
|
||||||
|
_db.SaveChanges();
|
||||||
|
|
||||||
|
return new AuthResponse(token, refreshToken, expiresAt, user.Username);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GenerateJwtToken(User user, DateTime expiresAt)
|
||||||
|
{
|
||||||
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(
|
||||||
|
_config["Jwt:Key"] ?? throw new InvalidOperationException("JWT key not configured")));
|
||||||
|
|
||||||
|
var claims = new[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||||
|
new Claim(ClaimTypes.Name, user.Username),
|
||||||
|
new Claim(ClaimTypes.Email, user.Email)
|
||||||
|
};
|
||||||
|
|
||||||
|
var token = new JwtSecurityToken(
|
||||||
|
issuer: _config["Jwt:Issuer"],
|
||||||
|
audience: _config["Jwt:Audience"],
|
||||||
|
claims: claims,
|
||||||
|
expires: expiresAt,
|
||||||
|
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));
|
||||||
|
|
||||||
|
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GenerateRefreshToken()
|
||||||
|
{
|
||||||
|
var bytes = RandomNumberGenerator.GetBytes(64);
|
||||||
|
return Convert.ToBase64String(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Purrse.Core.DTOs;
|
||||||
|
using Purrse.Core.Enums;
|
||||||
|
using Purrse.Core.Interfaces.Services;
|
||||||
|
using Purrse.Core.Models;
|
||||||
|
using Purrse.Data;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Services;
|
||||||
|
|
||||||
|
public class BudgetService : IBudgetService
|
||||||
|
{
|
||||||
|
private readonly PurrseDbContext _db;
|
||||||
|
|
||||||
|
public BudgetService(PurrseDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public async Task<BudgetResponse?> GetByMonthAsync(Guid userId, int year, int month)
|
||||||
|
{
|
||||||
|
var budget = await _db.Budgets
|
||||||
|
.Include(b => b.Items).ThenInclude(i => i.Category)
|
||||||
|
.FirstOrDefaultAsync(b => b.UserId == userId && b.Year == year && b.Month == month);
|
||||||
|
|
||||||
|
if (budget == null) return null;
|
||||||
|
|
||||||
|
var startDate = new DateTime(year, month, 1);
|
||||||
|
var endDate = startDate.AddMonths(1).AddDays(-1);
|
||||||
|
var actuals = await GetActualSpending(userId, startDate, endDate);
|
||||||
|
|
||||||
|
return MapToResponse(budget, actuals);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BudgetResponse> CreateAsync(Guid userId, CreateBudgetRequest request)
|
||||||
|
{
|
||||||
|
var budget = new Budget
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
Year = request.Year,
|
||||||
|
Month = request.Month,
|
||||||
|
Notes = request.Notes
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var item in request.Items)
|
||||||
|
{
|
||||||
|
budget.Items.Add(new BudgetItem
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
CategoryId = item.CategoryId,
|
||||||
|
BudgetedAmount = item.BudgetedAmount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_db.Budgets.Add(budget);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return MapToResponse(budget, new Dictionary<Guid, decimal>());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BudgetResponse> UpdateAsync(Guid userId, Guid budgetId, UpdateBudgetRequest request)
|
||||||
|
{
|
||||||
|
var budget = await _db.Budgets.Include(b => b.Items).ThenInclude(i => i.Category)
|
||||||
|
.FirstOrDefaultAsync(b => b.Id == budgetId && b.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Budget not found");
|
||||||
|
|
||||||
|
budget.Notes = request.Notes;
|
||||||
|
_db.BudgetItems.RemoveRange(budget.Items);
|
||||||
|
|
||||||
|
foreach (var item in request.Items)
|
||||||
|
{
|
||||||
|
budget.Items.Add(new BudgetItem
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
BudgetId = budgetId,
|
||||||
|
CategoryId = item.CategoryId,
|
||||||
|
BudgetedAmount = item.BudgetedAmount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var startDate = new DateTime(budget.Year, budget.Month, 1);
|
||||||
|
var endDate = startDate.AddMonths(1).AddDays(-1);
|
||||||
|
var actuals = await GetActualSpending(userId, startDate, endDate);
|
||||||
|
|
||||||
|
return MapToResponse(budget, actuals);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(Guid userId, Guid budgetId)
|
||||||
|
{
|
||||||
|
var budget = await _db.Budgets.FirstOrDefaultAsync(b => b.Id == budgetId && b.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Budget not found");
|
||||||
|
_db.Budgets.Remove(budget);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<BudgetItemResponse>> GetActualsAsync(Guid userId, Guid budgetId)
|
||||||
|
{
|
||||||
|
var budget = await _db.Budgets.Include(b => b.Items).ThenInclude(i => i.Category)
|
||||||
|
.FirstOrDefaultAsync(b => b.Id == budgetId && b.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Budget not found");
|
||||||
|
|
||||||
|
var startDate = new DateTime(budget.Year, budget.Month, 1);
|
||||||
|
var endDate = startDate.AddMonths(1).AddDays(-1);
|
||||||
|
var actuals = await GetActualSpending(userId, startDate, endDate);
|
||||||
|
|
||||||
|
return budget.Items.Select(i => new BudgetItemResponse(
|
||||||
|
i.Id, i.CategoryId, i.Category?.Name ?? "", i.BudgetedAmount,
|
||||||
|
actuals.GetValueOrDefault(i.CategoryId)
|
||||||
|
)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Dictionary<Guid, decimal>> GetActualSpending(Guid userId, DateTime startDate, DateTime endDate)
|
||||||
|
{
|
||||||
|
return await _db.Transactions
|
||||||
|
.Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate
|
||||||
|
&& t.CategoryId.HasValue && !t.IsVoid && t.Type == TransactionType.Debit)
|
||||||
|
.GroupBy(t => t.CategoryId!.Value)
|
||||||
|
.ToDictionaryAsync(g => g.Key, g => Math.Abs(g.Sum(t => t.Amount)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BudgetResponse MapToResponse(Budget b, Dictionary<Guid, decimal> actuals) => new(
|
||||||
|
b.Id, b.Year, b.Month, b.Notes,
|
||||||
|
b.Items.Select(i => new BudgetItemResponse(
|
||||||
|
i.Id, i.CategoryId, i.Category?.Name ?? "", i.BudgetedAmount,
|
||||||
|
actuals.GetValueOrDefault(i.CategoryId)
|
||||||
|
)).ToList(),
|
||||||
|
b.CreatedAt
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Purrse.Core.DTOs;
|
||||||
|
using Purrse.Core.Interfaces.Services;
|
||||||
|
using Purrse.Core.Models;
|
||||||
|
using Purrse.Data;
|
||||||
|
using Purrse.Data.Seeds;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Services;
|
||||||
|
|
||||||
|
public class CategoryService : ICategoryService
|
||||||
|
{
|
||||||
|
private readonly PurrseDbContext _db;
|
||||||
|
|
||||||
|
public CategoryService(PurrseDbContext db)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<CategoryResponse>> GetAllAsync(Guid userId)
|
||||||
|
{
|
||||||
|
return await _db.Categories
|
||||||
|
.Include(c => c.Parent)
|
||||||
|
.Where(c => c.UserId == userId)
|
||||||
|
.OrderBy(c => c.SortOrder)
|
||||||
|
.Select(c => new CategoryResponse(c.Id, c.Name, c.Type, c.ParentId, c.Parent != null ? c.Parent.Name : null, c.SortOrder, c.IsSystem, c.IsActive))
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<CategoryTreeResponse>> GetTreeAsync(Guid userId)
|
||||||
|
{
|
||||||
|
var categories = await _db.Categories
|
||||||
|
.Where(c => c.UserId == userId && c.IsActive)
|
||||||
|
.OrderBy(c => c.SortOrder)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return BuildTree(categories, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CategoryResponse?> GetByIdAsync(Guid userId, Guid categoryId)
|
||||||
|
{
|
||||||
|
var c = await _db.Categories.Include(c => c.Parent)
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == categoryId && c.UserId == userId);
|
||||||
|
return c == null ? null : new CategoryResponse(c.Id, c.Name, c.Type, c.ParentId, c.Parent?.Name, c.SortOrder, c.IsSystem, c.IsActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CategoryResponse> CreateAsync(Guid userId, CreateCategoryRequest request)
|
||||||
|
{
|
||||||
|
var category = new Category
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
Name = request.Name,
|
||||||
|
Type = request.Type,
|
||||||
|
ParentId = request.ParentId,
|
||||||
|
SortOrder = await _db.Categories.CountAsync(c => c.UserId == userId)
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.Categories.Add(category);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return new CategoryResponse(category.Id, category.Name, category.Type, category.ParentId, null, category.SortOrder, false, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CategoryResponse> UpdateAsync(Guid userId, Guid categoryId, UpdateCategoryRequest request)
|
||||||
|
{
|
||||||
|
var category = await _db.Categories.FirstOrDefaultAsync(c => c.Id == categoryId && c.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Category not found");
|
||||||
|
|
||||||
|
category.Name = request.Name;
|
||||||
|
category.ParentId = request.ParentId;
|
||||||
|
category.SortOrder = request.SortOrder;
|
||||||
|
category.IsActive = request.IsActive;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return new CategoryResponse(category.Id, category.Name, category.Type, category.ParentId, null, category.SortOrder, category.IsSystem, category.IsActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(Guid userId, Guid categoryId)
|
||||||
|
{
|
||||||
|
var category = await _db.Categories.FirstOrDefaultAsync(c => c.Id == categoryId && c.UserId == userId && !c.IsSystem)
|
||||||
|
?? throw new KeyNotFoundException("Category not found or is a system category");
|
||||||
|
|
||||||
|
_db.Categories.Remove(category);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SeedDefaultCategoriesAsync(Guid userId)
|
||||||
|
{
|
||||||
|
if (await _db.Categories.AnyAsync(c => c.UserId == userId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var categories = CategorySeeder.GetDefaultCategories(userId);
|
||||||
|
_db.Categories.AddRange(categories);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<CategoryTreeResponse> BuildTree(List<Category> all, Guid? parentId)
|
||||||
|
{
|
||||||
|
return all.Where(c => c.ParentId == parentId)
|
||||||
|
.Select(c => new CategoryTreeResponse(
|
||||||
|
c.Id, c.Name, c.Type, c.SortOrder, c.IsSystem, c.IsActive,
|
||||||
|
BuildTree(all, c.Id)))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Purrse.Core.DTOs;
|
||||||
|
using Purrse.Core.Enums;
|
||||||
|
using Purrse.Core.Interfaces.Services;
|
||||||
|
using Purrse.Data;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Services;
|
||||||
|
|
||||||
|
public class DashboardService : IDashboardService
|
||||||
|
{
|
||||||
|
private readonly PurrseDbContext _db;
|
||||||
|
|
||||||
|
public DashboardService(PurrseDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public async Task<DashboardResponse> GetDashboardAsync(Guid userId)
|
||||||
|
{
|
||||||
|
var accounts = await _db.Accounts
|
||||||
|
.Where(a => a.UserId == userId && a.IsActive)
|
||||||
|
.OrderBy(a => a.SortOrder)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var assetTypes = new[] { AccountType.Checking, AccountType.Savings, AccountType.Cash, AccountType.Brokerage, AccountType.Retirement401k, AccountType.IRA };
|
||||||
|
var totalAssets = accounts.Where(a => assetTypes.Contains(a.Type)).Sum(a => a.Balance);
|
||||||
|
var totalLiabilities = accounts.Where(a => !assetTypes.Contains(a.Type)).Sum(a => Math.Abs(a.Balance));
|
||||||
|
var netWorth = totalAssets - totalLiabilities;
|
||||||
|
|
||||||
|
var accountSummaries = accounts.Select(a => new AccountSummaryResponse(a.Id, a.Name, a.Type, a.Institution, a.Balance, a.CreditLimit)).ToList();
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var startOfMonth = new DateTime(now.Year, now.Month, 1);
|
||||||
|
var startOfLastMonth = startOfMonth.AddMonths(-1);
|
||||||
|
|
||||||
|
var recentTxns = await _db.Transactions
|
||||||
|
.Include(t => t.Account).Include(t => t.Category)
|
||||||
|
.Where(t => t.Account.UserId == userId && !t.IsVoid)
|
||||||
|
.OrderByDescending(t => t.Date).ThenByDescending(t => t.CreatedAt)
|
||||||
|
.Take(10)
|
||||||
|
.Select(t => new TransactionResponse(t.Id, t.AccountId, t.Account.Name, t.Date, t.Amount,
|
||||||
|
t.PayeeId, t.PayeeName, t.CategoryId, t.Category != null ? t.Category.Name : null,
|
||||||
|
t.Memo, t.ReferenceNumber, t.CheckNumber, t.Type, t.Status, t.IsVoid,
|
||||||
|
t.TransferTransactionId, t.ImportBatchId, null, t.CreatedAt))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var thisMonthSpending = await _db.Transactions
|
||||||
|
.Where(t => t.Account.UserId == userId && t.Date >= startOfMonth && t.Amount < 0 && !t.IsVoid)
|
||||||
|
.SumAsync(t => Math.Abs(t.Amount));
|
||||||
|
|
||||||
|
var lastMonthSpending = await _db.Transactions
|
||||||
|
.Where(t => t.Account.UserId == userId && t.Date >= startOfLastMonth && t.Date < startOfMonth && t.Amount < 0 && !t.IsVoid)
|
||||||
|
.SumAsync(t => Math.Abs(t.Amount));
|
||||||
|
|
||||||
|
var changePercent = lastMonthSpending == 0 ? 0 : ((thisMonthSpending - lastMonthSpending) / lastMonthSpending) * 100;
|
||||||
|
|
||||||
|
var topCategories = await _db.Transactions
|
||||||
|
.Include(t => t.Category)
|
||||||
|
.Where(t => t.Account.UserId == userId && t.Date >= startOfMonth && t.Amount < 0 && !t.IsVoid && t.CategoryId.HasValue)
|
||||||
|
.GroupBy(t => new { t.CategoryId, CategoryName = t.Category!.Name })
|
||||||
|
.Select(g => new CategorySpending(g.Key.CategoryName, Math.Abs(g.Sum(t => t.Amount)), 0, null))
|
||||||
|
.OrderByDescending(c => c.Amount)
|
||||||
|
.Take(5)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var totalSpending = topCategories.Sum(c => c.Amount);
|
||||||
|
topCategories = topCategories.Select(c => new CategorySpending(c.CategoryName, c.Amount,
|
||||||
|
totalSpending > 0 ? (c.Amount / totalSpending) * 100 : 0, null)).ToList();
|
||||||
|
|
||||||
|
var spendingSummary = new SpendingSummary(thisMonthSpending, lastMonthSpending, changePercent, topCategories);
|
||||||
|
|
||||||
|
var upcomingBills = await _db.ScheduledTransactions
|
||||||
|
.Include(s => s.Account)
|
||||||
|
.Where(s => s.UserId == userId && s.IsActive && s.NextDueDate <= now.AddDays(14))
|
||||||
|
.OrderBy(s => s.NextDueDate)
|
||||||
|
.Take(10)
|
||||||
|
.Select(s => new UpcomingBill(s.Id, s.PayeeName ?? "", s.Amount, s.NextDueDate, s.Account.Name, s.AutoPost))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return new DashboardResponse(netWorth, totalAssets, totalLiabilities, accountSummaries,
|
||||||
|
recentTxns, spendingSummary, upcomingBills);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Purrse.Core.DTOs;
|
||||||
|
using Purrse.Core.Helpers;
|
||||||
|
using Purrse.Core.Interfaces.Services;
|
||||||
|
using Purrse.Core.Models;
|
||||||
|
using Purrse.Data;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Services;
|
||||||
|
|
||||||
|
public class DuplicateDetectionService : IDuplicateDetectionService
|
||||||
|
{
|
||||||
|
private readonly PurrseDbContext _db;
|
||||||
|
|
||||||
|
public DuplicateDetectionService(PurrseDbContext db)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DuplicateMatch?> FindDuplicateAsync(Guid accountId, DateTime date, decimal amount, string? payeeName, string? fitId, string? checkNumber)
|
||||||
|
{
|
||||||
|
var fingerprint = TransactionFingerprint.Generate(accountId, date, amount, fitId);
|
||||||
|
|
||||||
|
// Check exact fingerprint match
|
||||||
|
var fpMatch = await _db.Transactions.FirstOrDefaultAsync(t => t.AccountId == accountId && t.Fingerprint == fingerprint);
|
||||||
|
if (fpMatch != null)
|
||||||
|
return new DuplicateMatch(fpMatch.Id, 1.0, "Exact fingerprint match");
|
||||||
|
|
||||||
|
// Check FITID match
|
||||||
|
if (!string.IsNullOrEmpty(fitId))
|
||||||
|
{
|
||||||
|
var fitIdMatch = await _db.Transactions.FirstOrDefaultAsync(t => t.AccountId == accountId && t.FitId == fitId);
|
||||||
|
if (fitIdMatch != null)
|
||||||
|
return new DuplicateMatch(fitIdMatch.Id, 0.95, "Matching FITID/Reference");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-factor scoring on nearby transactions
|
||||||
|
var candidates = await _db.Transactions
|
||||||
|
.Where(t => t.AccountId == accountId && t.Date >= date.AddDays(-3) && t.Date <= date.AddDays(3) && !t.IsVoid)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
DuplicateMatch? bestMatch = null;
|
||||||
|
|
||||||
|
foreach (var existing in candidates)
|
||||||
|
{
|
||||||
|
var confidence = CalculateConfidence(existing, date, amount, payeeName, fitId, checkNumber);
|
||||||
|
if (confidence >= 0.50 && (bestMatch == null || confidence > bestMatch.Confidence))
|
||||||
|
{
|
||||||
|
var reasons = new List<string>();
|
||||||
|
if (existing.Amount == amount) reasons.Add("amount");
|
||||||
|
if (existing.Date.Date == date.Date) reasons.Add("date");
|
||||||
|
if (!string.IsNullOrEmpty(payeeName) && !string.IsNullOrEmpty(existing.PayeeName))
|
||||||
|
reasons.Add("payee");
|
||||||
|
|
||||||
|
bestMatch = new DuplicateMatch(existing.Id, confidence, $"Matched on: {string.Join(", ", reasons)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double CalculateConfidence(Transaction existing, DateTime date, decimal amount, string? payeeName, string? fitId, string? checkNumber)
|
||||||
|
{
|
||||||
|
double score = 0;
|
||||||
|
|
||||||
|
// Amount matching
|
||||||
|
if (existing.Amount == amount)
|
||||||
|
score += 0.40;
|
||||||
|
else if (Math.Abs(existing.Amount - amount) <= 0.01m)
|
||||||
|
score += 0.35;
|
||||||
|
|
||||||
|
// Date matching
|
||||||
|
var daysDiff = Math.Abs((existing.Date.Date - date.Date).Days);
|
||||||
|
if (daysDiff == 0) score += 0.30;
|
||||||
|
else if (daysDiff <= 1) score += 0.20;
|
||||||
|
else if (daysDiff <= 2) score += 0.10;
|
||||||
|
|
||||||
|
// Payee matching (Jaro-Winkler)
|
||||||
|
if (!string.IsNullOrEmpty(payeeName) && !string.IsNullOrEmpty(existing.PayeeName))
|
||||||
|
{
|
||||||
|
var similarity = StringSimilarityHelper.Calculate(payeeName, existing.PayeeName);
|
||||||
|
if (similarity >= 0.85) score += 0.20;
|
||||||
|
else if (similarity >= 0.70) score += 0.10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check number matching
|
||||||
|
if (!string.IsNullOrEmpty(checkNumber) && existing.CheckNumber == checkNumber)
|
||||||
|
score += 0.10;
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
namespace Purrse.Api.Services;
|
||||||
|
|
||||||
|
public class FileWatcherService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly ILogger<FileWatcherService> _logger;
|
||||||
|
private readonly string _importsPath;
|
||||||
|
private FileSystemWatcher? _watcher;
|
||||||
|
|
||||||
|
public FileWatcherService(IServiceScopeFactory scopeFactory, ILogger<FileWatcherService> logger, IConfiguration config)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_logger = logger;
|
||||||
|
_importsPath = config["Imports:WatchPath"] ?? Path.Combine(AppContext.BaseDirectory, "imports");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(_importsPath))
|
||||||
|
Directory.CreateDirectory(_importsPath);
|
||||||
|
|
||||||
|
_watcher = new FileSystemWatcher(_importsPath)
|
||||||
|
{
|
||||||
|
NotifyFilter = NotifyFilters.FileName | NotifyFilters.CreationTime,
|
||||||
|
EnableRaisingEvents = true
|
||||||
|
};
|
||||||
|
|
||||||
|
_watcher.Created += OnFileCreated;
|
||||||
|
|
||||||
|
_logger.LogInformation("File watcher started, monitoring: {Path}", _importsPath);
|
||||||
|
|
||||||
|
stoppingToken.Register(() =>
|
||||||
|
{
|
||||||
|
_watcher?.Dispose();
|
||||||
|
_logger.LogInformation("File watcher stopped");
|
||||||
|
});
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnFileCreated(object sender, FileSystemEventArgs e)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("New file detected: {FileName}", e.Name);
|
||||||
|
// Files dropped here can be processed by the import service
|
||||||
|
// A SignalR notification would be sent to connected clients
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Purrse.Core.DTOs;
|
||||||
|
using Purrse.Core.Enums;
|
||||||
|
using Purrse.Core.Helpers;
|
||||||
|
using Purrse.Core.Interfaces.Services;
|
||||||
|
using Purrse.Core.Models;
|
||||||
|
using Purrse.Plugins.Abstractions;
|
||||||
|
using Purrse.Data;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Services;
|
||||||
|
|
||||||
|
public class ImportService : IImportService
|
||||||
|
{
|
||||||
|
private readonly PurrseDbContext _db;
|
||||||
|
private readonly IDuplicateDetectionService _duplicateService;
|
||||||
|
private readonly IPayeeService _payeeService;
|
||||||
|
private readonly IEnumerable<IFileParser> _parsers;
|
||||||
|
|
||||||
|
public ImportService(PurrseDbContext db, IDuplicateDetectionService duplicateService, IPayeeService payeeService, IEnumerable<IFileParser> parsers)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_duplicateService = duplicateService;
|
||||||
|
_payeeService = payeeService;
|
||||||
|
_parsers = parsers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ImportUploadResponse> UploadAsync(Guid userId, Guid accountId, string fileName, Stream fileStream)
|
||||||
|
{
|
||||||
|
var account = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Account not found");
|
||||||
|
|
||||||
|
var parser = _parsers.FirstOrDefault(p => p.CanParse(fileName, fileStream))
|
||||||
|
?? throw new InvalidOperationException($"No parser found for file: {fileName}");
|
||||||
|
|
||||||
|
fileStream.Position = 0;
|
||||||
|
var parseResult = await parser.ParseAsync(fileStream, fileName);
|
||||||
|
|
||||||
|
if (!parseResult.Success)
|
||||||
|
throw new InvalidOperationException(parseResult.ErrorMessage ?? "Failed to parse file");
|
||||||
|
|
||||||
|
var batch = new ImportBatch
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
AccountId = accountId,
|
||||||
|
FileName = fileName,
|
||||||
|
FileType = Path.GetExtension(fileName).TrimStart('.').ToUpperInvariant(),
|
||||||
|
TotalCount = parseResult.Transactions.Count,
|
||||||
|
Status = ImportStatus.Pending
|
||||||
|
};
|
||||||
|
|
||||||
|
var previews = new List<ImportedTransactionPreview>();
|
||||||
|
|
||||||
|
for (int i = 0; i < parseResult.Transactions.Count; i++)
|
||||||
|
{
|
||||||
|
var parsed = parseResult.Transactions[i];
|
||||||
|
var duplicate = await _duplicateService.FindDuplicateAsync(
|
||||||
|
accountId, parsed.Date, parsed.Amount, parsed.PayeeName, parsed.FitId, parsed.CheckNumber);
|
||||||
|
|
||||||
|
previews.Add(new ImportedTransactionPreview(
|
||||||
|
i, parsed.Date, parsed.Amount, parsed.PayeeName, parsed.Memo, parsed.FitId, duplicate));
|
||||||
|
}
|
||||||
|
|
||||||
|
_db.ImportBatches.Add(batch);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var newCount = previews.Count(p => p.DuplicateMatch == null);
|
||||||
|
var dupCount = previews.Count(p => p.DuplicateMatch != null);
|
||||||
|
|
||||||
|
return new ImportUploadResponse(batch.Id, parseResult.Transactions.Count, newCount, dupCount, previews);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ImportBatchResponse> ConfirmBatchAsync(Guid userId, Guid batchId)
|
||||||
|
{
|
||||||
|
var batch = await _db.ImportBatches.FirstOrDefaultAsync(b => b.Id == batchId && b.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Import batch not found");
|
||||||
|
|
||||||
|
batch.Status = ImportStatus.Confirmed;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return new ImportBatchResponse(batch.Id, batch.FileName, batch.FileType, batch.TotalCount,
|
||||||
|
batch.ImportedCount, batch.DuplicateCount, batch.SkippedCount, batch.Status.ToString(), batch.ImportedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ResolveDuplicatesAsync(Guid userId, Guid batchId, ResolveDuplicatesRequest request)
|
||||||
|
{
|
||||||
|
var batch = await _db.ImportBatches.FirstOrDefaultAsync(b => b.Id == batchId && b.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Import batch not found");
|
||||||
|
|
||||||
|
foreach (var resolution in request.Resolutions)
|
||||||
|
{
|
||||||
|
switch (resolution.Action)
|
||||||
|
{
|
||||||
|
case DuplicateAction.Skip:
|
||||||
|
batch.SkippedCount++;
|
||||||
|
break;
|
||||||
|
case DuplicateAction.Import:
|
||||||
|
batch.ImportedCount++;
|
||||||
|
break;
|
||||||
|
case DuplicateAction.Merge:
|
||||||
|
batch.DuplicateCount++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Purrse.Core.DTOs;
|
||||||
|
using Purrse.Core.Interfaces.Services;
|
||||||
|
using Purrse.Core.Models;
|
||||||
|
using Purrse.Data;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Services;
|
||||||
|
|
||||||
|
public class LoanService : ILoanService
|
||||||
|
{
|
||||||
|
private readonly PurrseDbContext _db;
|
||||||
|
|
||||||
|
public LoanService(PurrseDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public async Task<LoanDetailResponse?> GetLoanDetailAsync(Guid userId, Guid accountId)
|
||||||
|
{
|
||||||
|
var account = await _db.Accounts.Include(a => a.LoanDetail).ThenInclude(l => l!.AmortizationEntries)
|
||||||
|
.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == userId);
|
||||||
|
|
||||||
|
if (account?.LoanDetail == null) return null;
|
||||||
|
|
||||||
|
var l = account.LoanDetail;
|
||||||
|
var schedule = l.AmortizationEntries.OrderBy(e => e.PaymentNumber)
|
||||||
|
.Select(e => new AmortizationEntryResponse(e.PaymentNumber, e.PaymentDate, e.PaymentAmount, e.PrincipalAmount, e.InterestAmount, e.EscrowAmount, e.RemainingBalance))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new LoanDetailResponse(l.Id, l.AccountId, account.Name, l.OriginalBalance, account.Balance,
|
||||||
|
l.InterestRate, l.TermMonths, l.MonthlyPayment, l.OriginationDate, l.MaturityDate,
|
||||||
|
l.EscrowAmount, l.ExtraPayment, schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LoanDetailResponse> CreateLoanDetailAsync(Guid userId, Guid accountId, CreateLoanDetailRequest request)
|
||||||
|
{
|
||||||
|
var account = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Account not found");
|
||||||
|
|
||||||
|
var loanDetail = new LoanDetail
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
AccountId = accountId,
|
||||||
|
OriginalBalance = request.OriginalBalance,
|
||||||
|
InterestRate = request.InterestRate,
|
||||||
|
TermMonths = request.TermMonths,
|
||||||
|
MonthlyPayment = request.MonthlyPayment,
|
||||||
|
OriginationDate = request.OriginationDate,
|
||||||
|
MaturityDate = request.MaturityDate,
|
||||||
|
EscrowAmount = request.EscrowAmount,
|
||||||
|
ExtraPayment = request.ExtraPayment
|
||||||
|
};
|
||||||
|
|
||||||
|
var entries = GenerateAmortizationSchedule(loanDetail);
|
||||||
|
loanDetail.AmortizationEntries = entries;
|
||||||
|
|
||||||
|
_db.LoanDetails.Add(loanDetail);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var schedule = entries.Select(e => new AmortizationEntryResponse(e.PaymentNumber, e.PaymentDate, e.PaymentAmount, e.PrincipalAmount, e.InterestAmount, e.EscrowAmount, e.RemainingBalance)).ToList();
|
||||||
|
|
||||||
|
return new LoanDetailResponse(loanDetail.Id, accountId, account.Name, loanDetail.OriginalBalance,
|
||||||
|
account.Balance, loanDetail.InterestRate, loanDetail.TermMonths, loanDetail.MonthlyPayment,
|
||||||
|
loanDetail.OriginationDate, loanDetail.MaturityDate, loanDetail.EscrowAmount, loanDetail.ExtraPayment, schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LoanDetailResponse> UpdateLoanDetailAsync(Guid userId, Guid accountId, CreateLoanDetailRequest request)
|
||||||
|
{
|
||||||
|
var account = await _db.Accounts.Include(a => a.LoanDetail).ThenInclude(l => l!.AmortizationEntries)
|
||||||
|
.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Account not found");
|
||||||
|
|
||||||
|
var l = account.LoanDetail ?? throw new KeyNotFoundException("No loan detail found");
|
||||||
|
|
||||||
|
_db.AmortizationEntries.RemoveRange(l.AmortizationEntries);
|
||||||
|
|
||||||
|
l.OriginalBalance = request.OriginalBalance;
|
||||||
|
l.InterestRate = request.InterestRate;
|
||||||
|
l.TermMonths = request.TermMonths;
|
||||||
|
l.MonthlyPayment = request.MonthlyPayment;
|
||||||
|
l.OriginationDate = request.OriginationDate;
|
||||||
|
l.MaturityDate = request.MaturityDate;
|
||||||
|
l.EscrowAmount = request.EscrowAmount;
|
||||||
|
l.ExtraPayment = request.ExtraPayment;
|
||||||
|
|
||||||
|
var entries = GenerateAmortizationSchedule(l);
|
||||||
|
l.AmortizationEntries = entries;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var schedule = entries.Select(e => new AmortizationEntryResponse(e.PaymentNumber, e.PaymentDate, e.PaymentAmount, e.PrincipalAmount, e.InterestAmount, e.EscrowAmount, e.RemainingBalance)).ToList();
|
||||||
|
|
||||||
|
return new LoanDetailResponse(l.Id, accountId, account.Name, l.OriginalBalance, account.Balance,
|
||||||
|
l.InterestRate, l.TermMonths, l.MonthlyPayment, l.OriginationDate, l.MaturityDate,
|
||||||
|
l.EscrowAmount, l.ExtraPayment, schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<AmortizationEntryResponse>> GetAmortizationScheduleAsync(Guid userId, Guid accountId)
|
||||||
|
{
|
||||||
|
var detail = await GetLoanDetailAsync(userId, accountId)
|
||||||
|
?? throw new KeyNotFoundException("Loan detail not found");
|
||||||
|
return detail.AmortizationSchedule;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PayoffScenarioResponse> CalculatePayoffScenarioAsync(Guid userId, Guid accountId, PayoffScenarioRequest request)
|
||||||
|
{
|
||||||
|
var account = await _db.Accounts.Include(a => a.LoanDetail).ThenInclude(l => l!.AmortizationEntries)
|
||||||
|
.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Account not found");
|
||||||
|
|
||||||
|
var l = account.LoanDetail ?? throw new KeyNotFoundException("No loan detail found");
|
||||||
|
|
||||||
|
var originalSchedule = l.AmortizationEntries.OrderBy(e => e.PaymentNumber).ToList();
|
||||||
|
var totalInterestOriginal = originalSchedule.Sum(e => e.InterestAmount);
|
||||||
|
var originalPayoffDate = originalSchedule.Last().PaymentDate;
|
||||||
|
|
||||||
|
// Build new schedule with extra payments
|
||||||
|
var balance = Math.Abs(account.Balance);
|
||||||
|
var monthlyRate = l.InterestRate / 100 / 12;
|
||||||
|
var extraMonthly = request.ExtraMonthlyPayment ?? 0;
|
||||||
|
var basePayment = l.MonthlyPayment - (l.EscrowAmount ?? 0);
|
||||||
|
var paymentDate = DateTime.UtcNow.Date;
|
||||||
|
paymentDate = new DateTime(paymentDate.Year, paymentDate.Month, l.OriginationDate.Day);
|
||||||
|
|
||||||
|
var newEntries = new List<AmortizationEntryResponse>();
|
||||||
|
int paymentNum = 0;
|
||||||
|
|
||||||
|
// Apply lump sum if specified
|
||||||
|
if (request.LumpSumPayment.HasValue && request.LumpSumDate.HasValue)
|
||||||
|
{
|
||||||
|
balance -= request.LumpSumPayment.Value;
|
||||||
|
if (balance < 0) balance = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (balance > 0 && paymentNum < 600)
|
||||||
|
{
|
||||||
|
paymentNum++;
|
||||||
|
paymentDate = paymentDate.AddMonths(1);
|
||||||
|
|
||||||
|
var interest = balance * monthlyRate;
|
||||||
|
var principal = Math.Min(basePayment + extraMonthly - interest, balance);
|
||||||
|
if (principal < 0) principal = 0;
|
||||||
|
|
||||||
|
balance -= principal;
|
||||||
|
if (balance < 0.01m) balance = 0;
|
||||||
|
|
||||||
|
newEntries.Add(new AmortizationEntryResponse(
|
||||||
|
paymentNum, paymentDate, principal + interest + (l.EscrowAmount ?? 0),
|
||||||
|
principal, interest, l.EscrowAmount, balance));
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalInterestNew = newEntries.Sum(e => e.InterestAmount);
|
||||||
|
var newPayoffDate = newEntries.LastOrDefault()?.PaymentDate ?? originalPayoffDate;
|
||||||
|
var monthsSaved = ((originalPayoffDate.Year - newPayoffDate.Year) * 12) + (originalPayoffDate.Month - newPayoffDate.Month);
|
||||||
|
|
||||||
|
return new PayoffScenarioResponse(
|
||||||
|
originalPayoffDate, newPayoffDate, totalInterestOriginal, totalInterestNew,
|
||||||
|
totalInterestOriginal - totalInterestNew, monthsSaved, newEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<AmortizationEntry> GenerateAmortizationSchedule(LoanDetail loan)
|
||||||
|
{
|
||||||
|
var entries = new List<AmortizationEntry>();
|
||||||
|
var balance = loan.OriginalBalance;
|
||||||
|
var monthlyRate = loan.InterestRate / 100 / 12;
|
||||||
|
var payment = loan.MonthlyPayment - (loan.EscrowAmount ?? 0);
|
||||||
|
var extra = loan.ExtraPayment ?? 0;
|
||||||
|
var paymentDate = loan.OriginationDate;
|
||||||
|
|
||||||
|
for (int i = 1; i <= loan.TermMonths && balance > 0; i++)
|
||||||
|
{
|
||||||
|
paymentDate = paymentDate.AddMonths(1);
|
||||||
|
var interest = balance * monthlyRate;
|
||||||
|
var principal = Math.Min(payment + extra - interest, balance);
|
||||||
|
balance -= principal;
|
||||||
|
if (balance < 0.01m) balance = 0;
|
||||||
|
|
||||||
|
entries.Add(new AmortizationEntry
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
PaymentNumber = i,
|
||||||
|
PaymentDate = paymentDate,
|
||||||
|
PaymentAmount = principal + interest + (loan.EscrowAmount ?? 0),
|
||||||
|
PrincipalAmount = principal,
|
||||||
|
InterestAmount = interest,
|
||||||
|
EscrowAmount = loan.EscrowAmount,
|
||||||
|
RemainingBalance = balance
|
||||||
|
});
|
||||||
|
|
||||||
|
if (balance == 0) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Purrse.Core.DTOs;
|
||||||
|
using Purrse.Core.Helpers;
|
||||||
|
using Purrse.Core.Interfaces.Services;
|
||||||
|
using Purrse.Core.Models;
|
||||||
|
using Purrse.Data;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Services;
|
||||||
|
|
||||||
|
public class PayeeService : IPayeeService
|
||||||
|
{
|
||||||
|
private readonly PurrseDbContext _db;
|
||||||
|
|
||||||
|
public PayeeService(PurrseDbContext db)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<PayeeResponse>> GetAllAsync(Guid userId)
|
||||||
|
{
|
||||||
|
return await _db.Payees
|
||||||
|
.Include(p => p.Aliases).Include(p => p.DefaultCategory)
|
||||||
|
.Where(p => p.UserId == userId)
|
||||||
|
.OrderBy(p => p.Name)
|
||||||
|
.Select(p => new PayeeResponse(p.Id, p.Name, p.DefaultCategoryId, p.DefaultCategory != null ? p.DefaultCategory.Name : null, p.IsActive, p.Aliases.Select(a => a.Alias).ToList()))
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PayeeResponse?> GetByIdAsync(Guid userId, Guid payeeId)
|
||||||
|
{
|
||||||
|
var p = await _db.Payees.Include(p => p.Aliases).Include(p => p.DefaultCategory)
|
||||||
|
.FirstOrDefaultAsync(p => p.Id == payeeId && p.UserId == userId);
|
||||||
|
return p == null ? null : new PayeeResponse(p.Id, p.Name, p.DefaultCategoryId, p.DefaultCategory?.Name, p.IsActive, p.Aliases.Select(a => a.Alias).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PayeeResponse> CreateAsync(Guid userId, CreatePayeeRequest request)
|
||||||
|
{
|
||||||
|
var payee = new Payee
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
Name = request.Name,
|
||||||
|
DefaultCategoryId = request.DefaultCategoryId
|
||||||
|
};
|
||||||
|
|
||||||
|
if (request.Aliases?.Any() == true)
|
||||||
|
{
|
||||||
|
foreach (var alias in request.Aliases)
|
||||||
|
payee.Aliases.Add(new PayeeAlias { Id = Guid.NewGuid(), Alias = alias });
|
||||||
|
}
|
||||||
|
|
||||||
|
_db.Payees.Add(payee);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return new PayeeResponse(payee.Id, payee.Name, payee.DefaultCategoryId, null, true, request.Aliases ?? new());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PayeeResponse> UpdateAsync(Guid userId, Guid payeeId, UpdatePayeeRequest request)
|
||||||
|
{
|
||||||
|
var payee = await _db.Payees.Include(p => p.Aliases)
|
||||||
|
.FirstOrDefaultAsync(p => p.Id == payeeId && p.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Payee not found");
|
||||||
|
|
||||||
|
payee.Name = request.Name;
|
||||||
|
payee.DefaultCategoryId = request.DefaultCategoryId;
|
||||||
|
payee.IsActive = request.IsActive;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return new PayeeResponse(payee.Id, payee.Name, payee.DefaultCategoryId, null, payee.IsActive, payee.Aliases.Select(a => a.Alias).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(Guid userId, Guid payeeId)
|
||||||
|
{
|
||||||
|
var payee = await _db.Payees.FirstOrDefaultAsync(p => p.Id == payeeId && p.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Payee not found");
|
||||||
|
_db.Payees.Remove(payee);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddAliasAsync(Guid userId, Guid payeeId, string alias)
|
||||||
|
{
|
||||||
|
var payee = await _db.Payees.FirstOrDefaultAsync(p => p.Id == payeeId && p.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Payee not found");
|
||||||
|
_db.PayeeAliases.Add(new PayeeAlias { Id = Guid.NewGuid(), PayeeId = payeeId, Alias = alias });
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RemoveAliasAsync(Guid userId, Guid payeeId, Guid aliasId)
|
||||||
|
{
|
||||||
|
var alias = await _db.PayeeAliases.FirstOrDefaultAsync(a => a.Id == aliasId && a.PayeeId == payeeId)
|
||||||
|
?? throw new KeyNotFoundException("Alias not found");
|
||||||
|
_db.PayeeAliases.Remove(alias);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Payee?> MatchPayeeAsync(Guid userId, string importedName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(importedName)) return null;
|
||||||
|
|
||||||
|
// Check exact match on name
|
||||||
|
var exact = await _db.Payees.FirstOrDefaultAsync(p => p.UserId == userId && p.Name == importedName && p.IsActive);
|
||||||
|
if (exact != null) return exact;
|
||||||
|
|
||||||
|
// Check aliases
|
||||||
|
var aliasMatch = await _db.PayeeAliases
|
||||||
|
.Include(a => a.Payee)
|
||||||
|
.FirstOrDefaultAsync(a => a.Payee.UserId == userId && a.Alias == importedName);
|
||||||
|
if (aliasMatch != null) return aliasMatch.Payee;
|
||||||
|
|
||||||
|
// Fuzzy match
|
||||||
|
var payees = await _db.Payees.Include(p => p.Aliases)
|
||||||
|
.Where(p => p.UserId == userId && p.IsActive).ToListAsync();
|
||||||
|
|
||||||
|
Payee? bestMatch = null;
|
||||||
|
double bestScore = 0;
|
||||||
|
|
||||||
|
foreach (var payee in payees)
|
||||||
|
{
|
||||||
|
var score = StringSimilarityHelper.Calculate(importedName, payee.Name);
|
||||||
|
if (score > bestScore) { bestScore = score; bestMatch = payee; }
|
||||||
|
|
||||||
|
foreach (var alias in payee.Aliases)
|
||||||
|
{
|
||||||
|
score = StringSimilarityHelper.Calculate(importedName, alias.Alias);
|
||||||
|
if (score > bestScore) { bestScore = score; bestMatch = payee; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestScore >= 0.85 ? bestMatch : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.Loader;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Purrse.Plugins.Abstractions;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Services;
|
||||||
|
|
||||||
|
public class PluginService
|
||||||
|
{
|
||||||
|
private readonly ILogger<PluginService> _logger;
|
||||||
|
private readonly string _pluginsPath;
|
||||||
|
private readonly Dictionary<string, LoadedPlugin> _loadedPlugins = new();
|
||||||
|
|
||||||
|
public PluginService(ILogger<PluginService> logger, IConfiguration config)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_pluginsPath = config["Plugins:Path"] ?? Path.Combine(AppContext.BaseDirectory, "plugins");
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<IPlugin> GetPlugins() => _loadedPlugins.Values.Select(p => p.Instance);
|
||||||
|
public IEnumerable<T> GetPlugins<T>() where T : IPlugin => _loadedPlugins.Values.Select(p => p.Instance).OfType<T>();
|
||||||
|
|
||||||
|
public async Task DiscoverAndLoadPluginsAsync()
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(_pluginsPath))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_pluginsPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var dir in Directory.GetDirectories(_pluginsPath))
|
||||||
|
{
|
||||||
|
var manifestPath = Path.Combine(dir, "plugin.json");
|
||||||
|
if (!File.Exists(manifestPath)) continue;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = await File.ReadAllTextAsync(manifestPath);
|
||||||
|
var manifest = JsonSerializer.Deserialize<PluginManifest>(json);
|
||||||
|
if (manifest == null) continue;
|
||||||
|
|
||||||
|
await LoadPluginAsync(dir, manifest);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to load plugin from {Directory}", dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadPluginAsync(string directory, PluginManifest manifest)
|
||||||
|
{
|
||||||
|
var assemblyPath = Path.Combine(directory, manifest.EntryAssembly);
|
||||||
|
if (!File.Exists(assemblyPath))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Plugin assembly not found: {Path}", assemblyPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var loadContext = new AssemblyLoadContext(manifest.Id, isCollectible: true);
|
||||||
|
var assembly = loadContext.LoadFromAssemblyPath(assemblyPath);
|
||||||
|
var pluginType = assembly.GetType(manifest.EntryType);
|
||||||
|
|
||||||
|
if (pluginType == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Plugin type not found: {Type}", manifest.EntryType);
|
||||||
|
loadContext.Unload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Activator.CreateInstance(pluginType) is not IPlugin plugin)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Type {Type} does not implement IPlugin", manifest.EntryType);
|
||||||
|
loadContext.Unload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var context = new DefaultPluginContext(directory);
|
||||||
|
await plugin.InitializeAsync(context);
|
||||||
|
|
||||||
|
_loadedPlugins[manifest.Id] = new LoadedPlugin(manifest, plugin, loadContext);
|
||||||
|
_logger.LogInformation("Loaded plugin: {Name} v{Version}", manifest.Name, manifest.Version);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UnloadPluginAsync(string pluginId)
|
||||||
|
{
|
||||||
|
if (_loadedPlugins.TryGetValue(pluginId, out var loaded))
|
||||||
|
{
|
||||||
|
await loaded.Instance.ShutdownAsync();
|
||||||
|
loaded.LoadContext.Unload();
|
||||||
|
_loadedPlugins.Remove(pluginId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record LoadedPlugin(PluginManifest Manifest, IPlugin Instance, AssemblyLoadContext LoadContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class DefaultPluginContext : IPluginContext
|
||||||
|
{
|
||||||
|
public string PluginDataDirectory { get; }
|
||||||
|
public IServiceProvider Services { get; } = null!;
|
||||||
|
|
||||||
|
public DefaultPluginContext(string dataDir)
|
||||||
|
{
|
||||||
|
PluginDataDirectory = dataDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Log(string message, Purrse.Plugins.Abstractions.LogLevel level = Purrse.Plugins.Abstractions.LogLevel.Information)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[{level}] {message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Purrse.Core.DTOs;
|
||||||
|
using Purrse.Core.Enums;
|
||||||
|
using Purrse.Core.Interfaces.Services;
|
||||||
|
using Purrse.Data;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Services;
|
||||||
|
|
||||||
|
public class ReportService : IReportService
|
||||||
|
{
|
||||||
|
private readonly PurrseDbContext _db;
|
||||||
|
|
||||||
|
public ReportService(PurrseDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public async Task<SpendingByCategoryReport> GetSpendingByCategoryAsync(Guid userId, DateTime startDate, DateTime endDate, Guid? accountId = null)
|
||||||
|
{
|
||||||
|
var query = _db.Transactions
|
||||||
|
.Include(t => t.Category).ThenInclude(c => c!.Parent)
|
||||||
|
.Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate
|
||||||
|
&& t.Amount < 0 && !t.IsVoid && t.CategoryId.HasValue);
|
||||||
|
|
||||||
|
if (accountId.HasValue)
|
||||||
|
query = query.Where(t => t.AccountId == accountId.Value);
|
||||||
|
|
||||||
|
var groups = await query
|
||||||
|
.GroupBy(t => new { t.CategoryId, CategoryName = t.Category!.Name, ParentName = t.Category.Parent != null ? t.Category.Parent.Name : (string?)null })
|
||||||
|
.Select(g => new { g.Key.CategoryName, g.Key.ParentName, Amount = Math.Abs(g.Sum(t => t.Amount)) })
|
||||||
|
.OrderByDescending(g => g.Amount)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var total = groups.Sum(g => g.Amount);
|
||||||
|
var categories = groups.Select(g => new CategorySpending(
|
||||||
|
g.CategoryName, g.Amount, total > 0 ? (g.Amount / total) * 100 : 0, g.ParentName
|
||||||
|
)).ToList();
|
||||||
|
|
||||||
|
return new SpendingByCategoryReport(startDate, endDate, total, categories);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IncomeVsExpenseReport> GetIncomeVsExpenseAsync(Guid userId, DateTime startDate, DateTime endDate)
|
||||||
|
{
|
||||||
|
var transactions = await _db.Transactions
|
||||||
|
.Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate && !t.IsVoid && t.Type != TransactionType.Transfer)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var months = transactions
|
||||||
|
.GroupBy(t => new { t.Date.Year, t.Date.Month })
|
||||||
|
.Select(g =>
|
||||||
|
{
|
||||||
|
var income = g.Where(t => t.Amount > 0).Sum(t => t.Amount);
|
||||||
|
var expenses = Math.Abs(g.Where(t => t.Amount < 0).Sum(t => t.Amount));
|
||||||
|
return new MonthlyIncomeExpense(g.Key.Year, g.Key.Month, income, expenses, income - expenses);
|
||||||
|
})
|
||||||
|
.OrderBy(m => m.Year).ThenBy(m => m.Month)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new IncomeVsExpenseReport(startDate, endDate, months);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<NetWorthReport> GetNetWorthAsync(Guid userId, DateTime startDate, DateTime endDate)
|
||||||
|
{
|
||||||
|
var accounts = await _db.Accounts.Where(a => a.UserId == userId && a.IsActive).ToListAsync();
|
||||||
|
var assetTypes = new[] { AccountType.Checking, AccountType.Savings, AccountType.Cash, AccountType.Brokerage, AccountType.Retirement401k, AccountType.IRA };
|
||||||
|
|
||||||
|
var transactions = await _db.Transactions
|
||||||
|
.Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate && !t.IsVoid)
|
||||||
|
.OrderBy(t => t.Date)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var entries = new List<NetWorthEntry>();
|
||||||
|
var currentDate = startDate;
|
||||||
|
|
||||||
|
while (currentDate <= endDate)
|
||||||
|
{
|
||||||
|
var endOfMonth = new DateTime(currentDate.Year, currentDate.Month, DateTime.DaysInMonth(currentDate.Year, currentDate.Month));
|
||||||
|
var txnsToDate = transactions.Where(t => t.Date <= endOfMonth).Sum(t => t.Amount);
|
||||||
|
|
||||||
|
// Simplified: using current balances adjusted by future transactions
|
||||||
|
var assets = accounts.Where(a => assetTypes.Contains(a.Type)).Sum(a => a.Balance);
|
||||||
|
var liabilities = accounts.Where(a => !assetTypes.Contains(a.Type)).Sum(a => Math.Abs(a.Balance));
|
||||||
|
|
||||||
|
entries.Add(new NetWorthEntry(endOfMonth, assets, liabilities, assets - liabilities));
|
||||||
|
currentDate = currentDate.AddMonths(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NetWorthReport(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CashFlowReport> GetCashFlowAsync(Guid userId, DateTime startDate, DateTime endDate)
|
||||||
|
{
|
||||||
|
var transactions = await _db.Transactions
|
||||||
|
.Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate && !t.IsVoid && t.Type != TransactionType.Transfer)
|
||||||
|
.OrderBy(t => t.Date)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var groups = transactions.GroupBy(t => t.Date.Date)
|
||||||
|
.Select(g =>
|
||||||
|
{
|
||||||
|
var inflow = g.Where(t => t.Amount > 0).Sum(t => t.Amount);
|
||||||
|
var outflow = Math.Abs(g.Where(t => t.Amount < 0).Sum(t => t.Amount));
|
||||||
|
return new CashFlowEntry(g.Key, inflow, outflow, inflow - outflow);
|
||||||
|
})
|
||||||
|
.OrderBy(e => e.Date)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var totalInflow = groups.Sum(e => e.Inflow);
|
||||||
|
var totalOutflow = groups.Sum(e => e.Outflow);
|
||||||
|
|
||||||
|
return new CashFlowReport(startDate, endDate, totalInflow, totalOutflow, totalInflow - totalOutflow, groups);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Purrse.Core.Interfaces.Services;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Services;
|
||||||
|
|
||||||
|
public class ScheduledTransactionProcessor : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly ILogger<ScheduledTransactionProcessor> _logger;
|
||||||
|
|
||||||
|
public ScheduledTransactionProcessor(IServiceScopeFactory scopeFactory, ILogger<ScheduledTransactionProcessor> logger)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var service = scope.ServiceProvider.GetRequiredService<IScheduledTransactionService>();
|
||||||
|
await service.ProcessDueTransactionsAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error processing scheduled transactions");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Purrse.Core.DTOs;
|
||||||
|
using Purrse.Core.Enums;
|
||||||
|
using Purrse.Core.Interfaces.Services;
|
||||||
|
using Purrse.Core.Models;
|
||||||
|
using Purrse.Data;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Services;
|
||||||
|
|
||||||
|
public class ScheduledTransactionService : IScheduledTransactionService
|
||||||
|
{
|
||||||
|
private readonly PurrseDbContext _db;
|
||||||
|
private readonly ITransactionService _transactionService;
|
||||||
|
|
||||||
|
public ScheduledTransactionService(PurrseDbContext db, ITransactionService transactionService)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_transactionService = transactionService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<ScheduledTransactionResponse>> GetAllAsync(Guid userId)
|
||||||
|
{
|
||||||
|
return await _db.ScheduledTransactions
|
||||||
|
.Include(s => s.Account).Include(s => s.Category)
|
||||||
|
.Where(s => s.UserId == userId)
|
||||||
|
.OrderBy(s => s.NextDueDate)
|
||||||
|
.Select(s => MapToResponse(s))
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ScheduledTransactionResponse?> GetByIdAsync(Guid userId, Guid id)
|
||||||
|
{
|
||||||
|
var s = await _db.ScheduledTransactions.Include(s => s.Account).Include(s => s.Category)
|
||||||
|
.FirstOrDefaultAsync(s => s.Id == id && s.UserId == userId);
|
||||||
|
return s == null ? null : MapToResponse(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ScheduledTransactionResponse> CreateAsync(Guid userId, CreateScheduledTransactionRequest request)
|
||||||
|
{
|
||||||
|
var scheduled = new ScheduledTransaction
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
AccountId = request.AccountId,
|
||||||
|
Amount = request.Amount,
|
||||||
|
PayeeName = request.PayeeName,
|
||||||
|
PayeeId = request.PayeeId,
|
||||||
|
CategoryId = request.CategoryId,
|
||||||
|
Memo = request.Memo,
|
||||||
|
Type = request.Type,
|
||||||
|
Frequency = request.Frequency,
|
||||||
|
NextDueDate = request.NextDueDate,
|
||||||
|
EndDate = request.EndDate,
|
||||||
|
AutoPost = request.AutoPost,
|
||||||
|
ReminderDaysBefore = request.ReminderDaysBefore
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.ScheduledTransactions.Add(scheduled);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
scheduled = await _db.ScheduledTransactions.Include(s => s.Account).Include(s => s.Category)
|
||||||
|
.FirstAsync(s => s.Id == scheduled.Id);
|
||||||
|
|
||||||
|
return MapToResponse(scheduled);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ScheduledTransactionResponse> UpdateAsync(Guid userId, Guid id, UpdateScheduledTransactionRequest request)
|
||||||
|
{
|
||||||
|
var s = await _db.ScheduledTransactions.Include(s => s.Account).Include(s => s.Category)
|
||||||
|
.FirstOrDefaultAsync(s => s.Id == id && s.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Scheduled transaction not found");
|
||||||
|
|
||||||
|
s.Amount = request.Amount;
|
||||||
|
s.PayeeName = request.PayeeName;
|
||||||
|
s.PayeeId = request.PayeeId;
|
||||||
|
s.CategoryId = request.CategoryId;
|
||||||
|
s.Memo = request.Memo;
|
||||||
|
s.Type = request.Type;
|
||||||
|
s.Frequency = request.Frequency;
|
||||||
|
s.NextDueDate = request.NextDueDate;
|
||||||
|
s.EndDate = request.EndDate;
|
||||||
|
s.AutoPost = request.AutoPost;
|
||||||
|
s.ReminderDaysBefore = request.ReminderDaysBefore;
|
||||||
|
s.IsActive = request.IsActive;
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return MapToResponse(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(Guid userId, Guid id)
|
||||||
|
{
|
||||||
|
var s = await _db.ScheduledTransactions.FirstOrDefaultAsync(s => s.Id == id && s.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Scheduled transaction not found");
|
||||||
|
_db.ScheduledTransactions.Remove(s);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SkipAsync(Guid userId, Guid id)
|
||||||
|
{
|
||||||
|
var s = await _db.ScheduledTransactions.FirstOrDefaultAsync(s => s.Id == id && s.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Scheduled transaction not found");
|
||||||
|
|
||||||
|
s.NextDueDate = CalculateNextDate(s.NextDueDate, s.Frequency);
|
||||||
|
if (s.EndDate.HasValue && s.NextDueDate > s.EndDate.Value)
|
||||||
|
s.IsActive = false;
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TransactionResponse> PostNowAsync(Guid userId, Guid id)
|
||||||
|
{
|
||||||
|
var s = await _db.ScheduledTransactions.FirstOrDefaultAsync(s => s.Id == id && s.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Scheduled transaction not found");
|
||||||
|
|
||||||
|
var result = await _transactionService.CreateAsync(userId, new CreateTransactionRequest(
|
||||||
|
s.AccountId, s.NextDueDate, s.Amount, s.PayeeName, s.PayeeId,
|
||||||
|
s.CategoryId, s.Memo, null, null, s.Type, null));
|
||||||
|
|
||||||
|
s.NextDueDate = CalculateNextDate(s.NextDueDate, s.Frequency);
|
||||||
|
if (s.EndDate.HasValue && s.NextDueDate > s.EndDate.Value)
|
||||||
|
s.IsActive = false;
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ProcessDueTransactionsAsync()
|
||||||
|
{
|
||||||
|
var due = await _db.ScheduledTransactions
|
||||||
|
.Where(s => s.IsActive && s.AutoPost && s.NextDueDate <= DateTime.UtcNow.Date)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var s in due)
|
||||||
|
{
|
||||||
|
await PostNowAsync(s.UserId, s.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime CalculateNextDate(DateTime current, Frequency frequency) => frequency switch
|
||||||
|
{
|
||||||
|
Frequency.Daily => current.AddDays(1),
|
||||||
|
Frequency.Weekly => current.AddDays(7),
|
||||||
|
Frequency.BiWeekly => current.AddDays(14),
|
||||||
|
Frequency.SemiMonthly => current.Day <= 15 ? new DateTime(current.Year, current.Month, Math.Min(28, DateTime.DaysInMonth(current.Year, current.Month))) : current.AddMonths(1).AddDays(-(current.Day - 1)),
|
||||||
|
Frequency.Monthly => current.AddMonths(1),
|
||||||
|
Frequency.Quarterly => current.AddMonths(3),
|
||||||
|
Frequency.SemiAnnually => current.AddMonths(6),
|
||||||
|
Frequency.Annually => current.AddYears(1),
|
||||||
|
_ => current.AddMonths(1)
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ScheduledTransactionResponse MapToResponse(ScheduledTransaction s) => new(
|
||||||
|
s.Id, s.AccountId, s.Account?.Name ?? "", s.Amount, s.PayeeName,
|
||||||
|
s.CategoryId, s.Category?.Name, s.Memo, s.Type, s.Frequency,
|
||||||
|
s.NextDueDate, s.EndDate, s.AutoPost, s.ReminderDaysBefore, s.IsActive);
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Purrse.Core.DTOs;
|
||||||
|
using Purrse.Core.Enums;
|
||||||
|
using Purrse.Core.Helpers;
|
||||||
|
using Purrse.Core.Interfaces.Services;
|
||||||
|
using Purrse.Core.Models;
|
||||||
|
using Purrse.Data;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Services;
|
||||||
|
|
||||||
|
public class TransactionService : ITransactionService
|
||||||
|
{
|
||||||
|
private readonly PurrseDbContext _db;
|
||||||
|
private readonly IAccountService _accountService;
|
||||||
|
|
||||||
|
public TransactionService(PurrseDbContext db, IAccountService accountService)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_accountService = accountService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResult<TransactionResponse>> GetByAccountAsync(Guid userId, Guid accountId, int page, int pageSize)
|
||||||
|
{
|
||||||
|
var query = _db.Transactions
|
||||||
|
.Include(t => t.Payee).Include(t => t.Category).Include(t => t.Splits).ThenInclude(s => s.Category)
|
||||||
|
.Where(t => t.AccountId == accountId && t.Account.UserId == userId)
|
||||||
|
.OrderByDescending(t => t.Date).ThenByDescending(t => t.CreatedAt);
|
||||||
|
|
||||||
|
return await PaginateAsync(query, page, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TransactionResponse?> GetByIdAsync(Guid userId, Guid transactionId)
|
||||||
|
{
|
||||||
|
var t = await _db.Transactions
|
||||||
|
.Include(t => t.Account).Include(t => t.Payee).Include(t => t.Category)
|
||||||
|
.Include(t => t.Splits).ThenInclude(s => s.Category)
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == transactionId && t.Account.UserId == userId);
|
||||||
|
return t == null ? null : MapToResponse(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TransactionResponse> CreateAsync(Guid userId, CreateTransactionRequest request)
|
||||||
|
{
|
||||||
|
var account = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == request.AccountId && a.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Account not found");
|
||||||
|
|
||||||
|
var transaction = new Transaction
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
AccountId = request.AccountId,
|
||||||
|
Date = request.Date,
|
||||||
|
Amount = request.Amount,
|
||||||
|
PayeeName = request.PayeeName,
|
||||||
|
PayeeId = request.PayeeId,
|
||||||
|
CategoryId = request.CategoryId,
|
||||||
|
Memo = request.Memo,
|
||||||
|
ReferenceNumber = request.ReferenceNumber,
|
||||||
|
CheckNumber = request.CheckNumber,
|
||||||
|
Type = request.Type,
|
||||||
|
Fingerprint = TransactionFingerprint.Generate(request.AccountId, request.Date, request.Amount, null)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (request.Splits?.Any() == true)
|
||||||
|
{
|
||||||
|
foreach (var split in request.Splits)
|
||||||
|
{
|
||||||
|
transaction.Splits.Add(new TransactionSplit
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
CategoryId = split.CategoryId,
|
||||||
|
Amount = split.Amount,
|
||||||
|
Memo = split.Memo
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_db.Transactions.Add(transaction);
|
||||||
|
account.Balance += request.Amount;
|
||||||
|
account.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return MapToResponse(transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TransactionResponse> UpdateAsync(Guid userId, Guid transactionId, UpdateTransactionRequest request)
|
||||||
|
{
|
||||||
|
var transaction = await _db.Transactions
|
||||||
|
.Include(t => t.Account).Include(t => t.Splits)
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == transactionId && t.Account.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Transaction not found");
|
||||||
|
|
||||||
|
var oldAmount = transaction.Amount;
|
||||||
|
transaction.Date = request.Date;
|
||||||
|
transaction.Amount = request.Amount;
|
||||||
|
transaction.PayeeName = request.PayeeName;
|
||||||
|
transaction.PayeeId = request.PayeeId;
|
||||||
|
transaction.CategoryId = request.CategoryId;
|
||||||
|
transaction.Memo = request.Memo;
|
||||||
|
transaction.ReferenceNumber = request.ReferenceNumber;
|
||||||
|
transaction.CheckNumber = request.CheckNumber;
|
||||||
|
transaction.Type = request.Type;
|
||||||
|
transaction.Status = request.Status;
|
||||||
|
transaction.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Update splits
|
||||||
|
_db.TransactionSplits.RemoveRange(transaction.Splits);
|
||||||
|
if (request.Splits?.Any() == true)
|
||||||
|
{
|
||||||
|
foreach (var split in request.Splits)
|
||||||
|
{
|
||||||
|
transaction.Splits.Add(new TransactionSplit
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
TransactionId = transactionId,
|
||||||
|
CategoryId = split.CategoryId,
|
||||||
|
Amount = split.Amount,
|
||||||
|
Memo = split.Memo
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction.Account.Balance += (request.Amount - oldAmount);
|
||||||
|
transaction.Account.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return MapToResponse(transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(Guid userId, Guid transactionId)
|
||||||
|
{
|
||||||
|
var transaction = await _db.Transactions.Include(t => t.Account)
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == transactionId && t.Account.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Transaction not found");
|
||||||
|
|
||||||
|
transaction.Account.Balance -= transaction.Amount;
|
||||||
|
_db.Transactions.Remove(transaction);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResult<TransactionResponse>> SearchAsync(Guid userId, TransactionSearchRequest request)
|
||||||
|
{
|
||||||
|
var query = _db.Transactions
|
||||||
|
.Include(t => t.Account).Include(t => t.Payee).Include(t => t.Category)
|
||||||
|
.Include(t => t.Splits).ThenInclude(s => s.Category)
|
||||||
|
.Where(t => t.Account.UserId == userId);
|
||||||
|
|
||||||
|
if (request.AccountId.HasValue)
|
||||||
|
query = query.Where(t => t.AccountId == request.AccountId.Value);
|
||||||
|
if (request.StartDate.HasValue)
|
||||||
|
query = query.Where(t => t.Date >= request.StartDate.Value);
|
||||||
|
if (request.EndDate.HasValue)
|
||||||
|
query = query.Where(t => t.Date <= request.EndDate.Value);
|
||||||
|
if (request.MinAmount.HasValue)
|
||||||
|
query = query.Where(t => t.Amount >= request.MinAmount.Value);
|
||||||
|
if (request.MaxAmount.HasValue)
|
||||||
|
query = query.Where(t => t.Amount <= request.MaxAmount.Value);
|
||||||
|
if (request.CategoryId.HasValue)
|
||||||
|
query = query.Where(t => t.CategoryId == request.CategoryId.Value);
|
||||||
|
if (request.Status.HasValue)
|
||||||
|
query = query.Where(t => t.Status == request.Status.Value);
|
||||||
|
if (!string.IsNullOrEmpty(request.PayeeName))
|
||||||
|
query = query.Where(t => t.PayeeName != null && t.PayeeName.Contains(request.PayeeName));
|
||||||
|
if (!string.IsNullOrEmpty(request.SearchText))
|
||||||
|
query = query.Where(t =>
|
||||||
|
(t.PayeeName != null && t.PayeeName.Contains(request.SearchText)) ||
|
||||||
|
(t.Memo != null && t.Memo.Contains(request.SearchText)) ||
|
||||||
|
(t.ReferenceNumber != null && t.ReferenceNumber.Contains(request.SearchText)));
|
||||||
|
|
||||||
|
query = query.OrderByDescending(t => t.Date).ThenByDescending(t => t.CreatedAt);
|
||||||
|
|
||||||
|
return await PaginateAsync(query, request.Page, request.PageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(TransactionResponse From, TransactionResponse To)> CreateTransferAsync(Guid userId, CreateTransferRequest request)
|
||||||
|
{
|
||||||
|
var fromAccount = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == request.FromAccountId && a.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Source account not found");
|
||||||
|
var toAccount = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == request.ToAccountId && a.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Destination account not found");
|
||||||
|
|
||||||
|
var fromTxn = new Transaction
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
AccountId = request.FromAccountId,
|
||||||
|
Date = request.Date,
|
||||||
|
Amount = -Math.Abs(request.Amount),
|
||||||
|
PayeeName = $"Transfer to {toAccount.Name}",
|
||||||
|
Memo = request.Memo,
|
||||||
|
Type = TransactionType.Transfer,
|
||||||
|
Fingerprint = TransactionFingerprint.Generate(request.FromAccountId, request.Date, -Math.Abs(request.Amount), null)
|
||||||
|
};
|
||||||
|
|
||||||
|
var toTxn = new Transaction
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
AccountId = request.ToAccountId,
|
||||||
|
Date = request.Date,
|
||||||
|
Amount = Math.Abs(request.Amount),
|
||||||
|
PayeeName = $"Transfer from {fromAccount.Name}",
|
||||||
|
Memo = request.Memo,
|
||||||
|
Type = TransactionType.Transfer,
|
||||||
|
Fingerprint = TransactionFingerprint.Generate(request.ToAccountId, request.Date, Math.Abs(request.Amount), null)
|
||||||
|
};
|
||||||
|
|
||||||
|
fromTxn.TransferTransactionId = toTxn.Id;
|
||||||
|
toTxn.TransferTransactionId = fromTxn.Id;
|
||||||
|
|
||||||
|
fromAccount.Balance += fromTxn.Amount;
|
||||||
|
toAccount.Balance += toTxn.Amount;
|
||||||
|
|
||||||
|
_db.Transactions.AddRange(fromTxn, toTxn);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return (MapToResponse(fromTxn), MapToResponse(toTxn));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task VoidAsync(Guid userId, Guid transactionId)
|
||||||
|
{
|
||||||
|
var transaction = await _db.Transactions.Include(t => t.Account)
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == transactionId && t.Account.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Transaction not found");
|
||||||
|
|
||||||
|
if (!transaction.IsVoid)
|
||||||
|
{
|
||||||
|
transaction.IsVoid = true;
|
||||||
|
transaction.Account.Balance -= transaction.Amount;
|
||||||
|
transaction.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<PagedResult<TransactionResponse>> PaginateAsync(IQueryable<Transaction> query, int page, int pageSize)
|
||||||
|
{
|
||||||
|
var totalCount = await query.CountAsync();
|
||||||
|
var items = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
||||||
|
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
||||||
|
|
||||||
|
return new PagedResult<TransactionResponse>(
|
||||||
|
items.Select(MapToResponse).ToList(), totalCount, page, pageSize, totalPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TransactionResponse MapToResponse(Transaction t) => new(
|
||||||
|
t.Id, t.AccountId, t.Account?.Name, t.Date, t.Amount,
|
||||||
|
t.PayeeId, t.PayeeName, t.CategoryId, t.Category?.Name,
|
||||||
|
t.Memo, t.ReferenceNumber, t.CheckNumber, t.Type, t.Status, t.IsVoid,
|
||||||
|
t.TransferTransactionId, t.ImportBatchId,
|
||||||
|
t.Splits?.Select(s => new SplitResponse(s.Id, s.CategoryId, s.Category?.Name, s.Amount, s.Memo)).ToList(),
|
||||||
|
t.CreatedAt
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "Host=localhost;Port=5433;Database=purrse;Username=purrse;Password=purrse_dev"
|
||||||
|
},
|
||||||
|
"Jwt": {
|
||||||
|
"Key": "PurrseDefaultSecretKey_ChangeInProduction_AtLeast32Chars!",
|
||||||
|
"Issuer": "Purrse",
|
||||||
|
"Audience": "Purrse"
|
||||||
|
},
|
||||||
|
"Cors": {
|
||||||
|
"Origins": [ "http://localhost:5173", "http://localhost:8080" ]
|
||||||
|
},
|
||||||
|
"Plugins": {
|
||||||
|
"Path": "plugins"
|
||||||
|
},
|
||||||
|
"Imports": {
|
||||||
|
"WatchPath": "imports"
|
||||||
|
},
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"Microsoft.EntityFrameworkCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using Purrse.Core.Enums;
|
||||||
|
|
||||||
|
namespace Purrse.Core.DTOs;
|
||||||
|
|
||||||
|
public record CreateAccountRequest(
|
||||||
|
string Name,
|
||||||
|
AccountType Type,
|
||||||
|
string? Institution,
|
||||||
|
string? AccountNumber,
|
||||||
|
decimal Balance,
|
||||||
|
decimal? CreditLimit,
|
||||||
|
decimal? InterestRate,
|
||||||
|
string? Notes
|
||||||
|
);
|
||||||
|
|
||||||
|
public record UpdateAccountRequest(
|
||||||
|
string Name,
|
||||||
|
string? Institution,
|
||||||
|
string? AccountNumber,
|
||||||
|
decimal? CreditLimit,
|
||||||
|
decimal? InterestRate,
|
||||||
|
string? Notes,
|
||||||
|
bool IsActive,
|
||||||
|
int SortOrder
|
||||||
|
);
|
||||||
|
|
||||||
|
public record AccountResponse(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
AccountType Type,
|
||||||
|
string? Institution,
|
||||||
|
string? AccountNumber,
|
||||||
|
decimal Balance,
|
||||||
|
decimal? CreditLimit,
|
||||||
|
decimal? InterestRate,
|
||||||
|
bool IsActive,
|
||||||
|
bool IsClosed,
|
||||||
|
string? Notes,
|
||||||
|
int SortOrder,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
bool HasLoanDetail
|
||||||
|
);
|
||||||
|
|
||||||
|
public record AccountSummaryResponse(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
AccountType Type,
|
||||||
|
string? Institution,
|
||||||
|
decimal Balance,
|
||||||
|
decimal? CreditLimit
|
||||||
|
);
|
||||||
|
|
||||||
|
public record BalanceHistoryEntry(DateTime Date, decimal Balance);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Purrse.Core.DTOs;
|
||||||
|
|
||||||
|
public record RegisterRequest(string Username, string Email, string Password);
|
||||||
|
public record LoginRequest(string Username, string Password);
|
||||||
|
public record AuthResponse(string Token, string RefreshToken, DateTime ExpiresAt, string Username);
|
||||||
|
public record RefreshRequest(string RefreshToken);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace Purrse.Core.DTOs;
|
||||||
|
|
||||||
|
public record CreateBudgetRequest(int Year, int Month, string? Notes, List<BudgetItemRequest> Items);
|
||||||
|
public record UpdateBudgetRequest(string? Notes, List<BudgetItemRequest> Items);
|
||||||
|
public record BudgetItemRequest(Guid CategoryId, decimal BudgetedAmount);
|
||||||
|
|
||||||
|
public record BudgetResponse(
|
||||||
|
Guid Id,
|
||||||
|
int Year,
|
||||||
|
int Month,
|
||||||
|
string? Notes,
|
||||||
|
List<BudgetItemResponse> Items,
|
||||||
|
DateTime CreatedAt
|
||||||
|
);
|
||||||
|
|
||||||
|
public record BudgetItemResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid CategoryId,
|
||||||
|
string CategoryName,
|
||||||
|
decimal BudgetedAmount,
|
||||||
|
decimal ActualAmount
|
||||||
|
);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Purrse.Core.Enums;
|
||||||
|
|
||||||
|
namespace Purrse.Core.DTOs;
|
||||||
|
|
||||||
|
public record CreateCategoryRequest(string Name, CategoryType Type, Guid? ParentId);
|
||||||
|
public record UpdateCategoryRequest(string Name, Guid? ParentId, int SortOrder, bool IsActive);
|
||||||
|
|
||||||
|
public record CategoryResponse(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
CategoryType Type,
|
||||||
|
Guid? ParentId,
|
||||||
|
string? ParentName,
|
||||||
|
int SortOrder,
|
||||||
|
bool IsSystem,
|
||||||
|
bool IsActive
|
||||||
|
);
|
||||||
|
|
||||||
|
public record CategoryTreeResponse(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
CategoryType Type,
|
||||||
|
int SortOrder,
|
||||||
|
bool IsSystem,
|
||||||
|
bool IsActive,
|
||||||
|
List<CategoryTreeResponse> Children
|
||||||
|
);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace Purrse.Core.DTOs;
|
||||||
|
|
||||||
|
public record DashboardResponse(
|
||||||
|
decimal NetWorth,
|
||||||
|
decimal TotalAssets,
|
||||||
|
decimal TotalLiabilities,
|
||||||
|
List<AccountSummaryResponse> Accounts,
|
||||||
|
List<TransactionResponse> RecentTransactions,
|
||||||
|
SpendingSummary SpendingSummary,
|
||||||
|
List<UpcomingBill> UpcomingBills
|
||||||
|
);
|
||||||
|
|
||||||
|
public record SpendingSummary(
|
||||||
|
decimal ThisMonth,
|
||||||
|
decimal LastMonth,
|
||||||
|
decimal ChangePercent,
|
||||||
|
List<CategorySpending> TopCategories
|
||||||
|
);
|
||||||
|
|
||||||
|
public record UpcomingBill(
|
||||||
|
Guid Id,
|
||||||
|
string PayeeName,
|
||||||
|
decimal Amount,
|
||||||
|
DateTime DueDate,
|
||||||
|
string AccountName,
|
||||||
|
bool AutoPost
|
||||||
|
);
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using Purrse.Core.Enums;
|
||||||
|
|
||||||
|
namespace Purrse.Core.DTOs;
|
||||||
|
|
||||||
|
public record ImportUploadResponse(
|
||||||
|
Guid BatchId,
|
||||||
|
int TotalTransactions,
|
||||||
|
int NewTransactions,
|
||||||
|
int PossibleDuplicates,
|
||||||
|
List<ImportedTransactionPreview> Previews
|
||||||
|
);
|
||||||
|
|
||||||
|
public record ImportedTransactionPreview(
|
||||||
|
int Index,
|
||||||
|
DateTime Date,
|
||||||
|
decimal Amount,
|
||||||
|
string? PayeeName,
|
||||||
|
string? Memo,
|
||||||
|
string? FitId,
|
||||||
|
DuplicateMatch? DuplicateMatch
|
||||||
|
);
|
||||||
|
|
||||||
|
public record DuplicateMatch(
|
||||||
|
Guid ExistingTransactionId,
|
||||||
|
double Confidence,
|
||||||
|
string MatchReason
|
||||||
|
);
|
||||||
|
|
||||||
|
public record ResolveDuplicatesRequest(List<DuplicateResolution> Resolutions);
|
||||||
|
|
||||||
|
public record DuplicateResolution(int Index, DuplicateAction Action);
|
||||||
|
|
||||||
|
public enum DuplicateAction
|
||||||
|
{
|
||||||
|
Import,
|
||||||
|
Skip,
|
||||||
|
Merge
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace Purrse.Core.DTOs;
|
||||||
|
|
||||||
|
public record InvestmentHoldingResponse(
|
||||||
|
Guid Id,
|
||||||
|
string Symbol,
|
||||||
|
string SecurityName,
|
||||||
|
decimal Shares,
|
||||||
|
decimal CostBasis,
|
||||||
|
decimal CurrentPrice,
|
||||||
|
decimal MarketValue,
|
||||||
|
decimal GainLoss,
|
||||||
|
decimal GainLossPercent,
|
||||||
|
DateTime AsOfDate
|
||||||
|
);
|
||||||
|
|
||||||
|
public record InvestmentPerformanceResponse(
|
||||||
|
decimal TotalMarketValue,
|
||||||
|
decimal TotalCostBasis,
|
||||||
|
decimal TotalGainLoss,
|
||||||
|
decimal TotalGainLossPercent,
|
||||||
|
List<InvestmentHoldingResponse> Holdings
|
||||||
|
);
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
namespace Purrse.Core.DTOs;
|
||||||
|
|
||||||
|
public record CreateLoanDetailRequest(
|
||||||
|
decimal OriginalBalance,
|
||||||
|
decimal InterestRate,
|
||||||
|
int TermMonths,
|
||||||
|
decimal MonthlyPayment,
|
||||||
|
DateTime OriginationDate,
|
||||||
|
DateTime MaturityDate,
|
||||||
|
decimal? EscrowAmount,
|
||||||
|
decimal? ExtraPayment
|
||||||
|
);
|
||||||
|
|
||||||
|
public record LoanDetailResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid AccountId,
|
||||||
|
string AccountName,
|
||||||
|
decimal OriginalBalance,
|
||||||
|
decimal CurrentBalance,
|
||||||
|
decimal InterestRate,
|
||||||
|
int TermMonths,
|
||||||
|
decimal MonthlyPayment,
|
||||||
|
DateTime OriginationDate,
|
||||||
|
DateTime MaturityDate,
|
||||||
|
decimal? EscrowAmount,
|
||||||
|
decimal? ExtraPayment,
|
||||||
|
List<AmortizationEntryResponse> AmortizationSchedule
|
||||||
|
);
|
||||||
|
|
||||||
|
public record AmortizationEntryResponse(
|
||||||
|
int PaymentNumber,
|
||||||
|
DateTime PaymentDate,
|
||||||
|
decimal PaymentAmount,
|
||||||
|
decimal PrincipalAmount,
|
||||||
|
decimal InterestAmount,
|
||||||
|
decimal? EscrowAmount,
|
||||||
|
decimal RemainingBalance
|
||||||
|
);
|
||||||
|
|
||||||
|
public record PayoffScenarioRequest(decimal? ExtraMonthlyPayment, decimal? LumpSumPayment, DateTime? LumpSumDate);
|
||||||
|
|
||||||
|
public record PayoffScenarioResponse(
|
||||||
|
DateTime OriginalPayoffDate,
|
||||||
|
DateTime NewPayoffDate,
|
||||||
|
decimal TotalInterestOriginal,
|
||||||
|
decimal TotalInterestNew,
|
||||||
|
decimal InterestSaved,
|
||||||
|
int MonthsSaved,
|
||||||
|
List<AmortizationEntryResponse> NewSchedule
|
||||||
|
);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace Purrse.Core.DTOs;
|
||||||
|
|
||||||
|
public record CreatePayeeRequest(string Name, Guid? DefaultCategoryId, List<string>? Aliases);
|
||||||
|
public record UpdatePayeeRequest(string Name, Guid? DefaultCategoryId, bool IsActive);
|
||||||
|
|
||||||
|
public record PayeeResponse(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
Guid? DefaultCategoryId,
|
||||||
|
string? DefaultCategoryName,
|
||||||
|
bool IsActive,
|
||||||
|
List<string> Aliases
|
||||||
|
);
|
||||||
|
|
||||||
|
public record CreatePayeeAliasRequest(string Alias);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Purrse.Core.DTOs;
|
||||||
|
|
||||||
|
public record PluginResponse(
|
||||||
|
Guid Id,
|
||||||
|
string PluginId,
|
||||||
|
string Name,
|
||||||
|
string Version,
|
||||||
|
bool IsEnabled,
|
||||||
|
DateTime InstalledAt,
|
||||||
|
List<string> Capabilities,
|
||||||
|
Dictionary<string, string> Configuration
|
||||||
|
);
|
||||||
|
|
||||||
|
public record PluginConfigurationRequest(Dictionary<string, string> Settings);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Purrse.Core.DTOs;
|
||||||
|
|
||||||
|
public record StartReconciliationRequest(DateTime StatementDate, decimal StatementBalance);
|
||||||
|
|
||||||
|
public record ReconciliationResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid AccountId,
|
||||||
|
DateTime StatementDate,
|
||||||
|
decimal StatementBalance,
|
||||||
|
decimal ClearedBalance,
|
||||||
|
decimal Difference,
|
||||||
|
bool IsCompleted,
|
||||||
|
List<TransactionResponse> UnclearedTransactions
|
||||||
|
);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace Purrse.Core.DTOs;
|
||||||
|
|
||||||
|
public record SpendingByCategoryReport(
|
||||||
|
DateTime StartDate,
|
||||||
|
DateTime EndDate,
|
||||||
|
decimal TotalSpending,
|
||||||
|
List<CategorySpending> Categories
|
||||||
|
);
|
||||||
|
|
||||||
|
public record CategorySpending(string CategoryName, decimal Amount, decimal Percentage, string? ParentCategoryName);
|
||||||
|
|
||||||
|
public record IncomeVsExpenseReport(
|
||||||
|
DateTime StartDate,
|
||||||
|
DateTime EndDate,
|
||||||
|
List<MonthlyIncomeExpense> Months
|
||||||
|
);
|
||||||
|
|
||||||
|
public record MonthlyIncomeExpense(int Year, int Month, decimal Income, decimal Expenses, decimal Net);
|
||||||
|
|
||||||
|
public record NetWorthReport(List<NetWorthEntry> Entries);
|
||||||
|
public record NetWorthEntry(DateTime Date, decimal Assets, decimal Liabilities, decimal NetWorth);
|
||||||
|
|
||||||
|
public record CashFlowReport(
|
||||||
|
DateTime StartDate,
|
||||||
|
DateTime EndDate,
|
||||||
|
decimal TotalInflow,
|
||||||
|
decimal TotalOutflow,
|
||||||
|
decimal NetCashFlow,
|
||||||
|
List<CashFlowEntry> Entries
|
||||||
|
);
|
||||||
|
|
||||||
|
public record CashFlowEntry(DateTime Date, decimal Inflow, decimal Outflow, decimal Net);
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using Purrse.Core.Enums;
|
||||||
|
|
||||||
|
namespace Purrse.Core.DTOs;
|
||||||
|
|
||||||
|
public record CreateScheduledTransactionRequest(
|
||||||
|
Guid AccountId,
|
||||||
|
decimal Amount,
|
||||||
|
string? PayeeName,
|
||||||
|
Guid? PayeeId,
|
||||||
|
Guid? CategoryId,
|
||||||
|
string? Memo,
|
||||||
|
TransactionType Type,
|
||||||
|
Frequency Frequency,
|
||||||
|
DateTime NextDueDate,
|
||||||
|
DateTime? EndDate,
|
||||||
|
bool AutoPost,
|
||||||
|
int ReminderDaysBefore
|
||||||
|
);
|
||||||
|
|
||||||
|
public record UpdateScheduledTransactionRequest(
|
||||||
|
decimal Amount,
|
||||||
|
string? PayeeName,
|
||||||
|
Guid? PayeeId,
|
||||||
|
Guid? CategoryId,
|
||||||
|
string? Memo,
|
||||||
|
TransactionType Type,
|
||||||
|
Frequency Frequency,
|
||||||
|
DateTime NextDueDate,
|
||||||
|
DateTime? EndDate,
|
||||||
|
bool AutoPost,
|
||||||
|
int ReminderDaysBefore,
|
||||||
|
bool IsActive
|
||||||
|
);
|
||||||
|
|
||||||
|
public record ScheduledTransactionResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid AccountId,
|
||||||
|
string AccountName,
|
||||||
|
decimal Amount,
|
||||||
|
string? PayeeName,
|
||||||
|
Guid? CategoryId,
|
||||||
|
string? CategoryName,
|
||||||
|
string? Memo,
|
||||||
|
TransactionType Type,
|
||||||
|
Frequency Frequency,
|
||||||
|
DateTime NextDueDate,
|
||||||
|
DateTime? EndDate,
|
||||||
|
bool AutoPost,
|
||||||
|
int ReminderDaysBefore,
|
||||||
|
bool IsActive
|
||||||
|
);
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using Purrse.Core.Enums;
|
||||||
|
|
||||||
|
namespace Purrse.Core.DTOs;
|
||||||
|
|
||||||
|
public record CreateTransactionRequest(
|
||||||
|
Guid AccountId,
|
||||||
|
DateTime Date,
|
||||||
|
decimal Amount,
|
||||||
|
string? PayeeName,
|
||||||
|
Guid? PayeeId,
|
||||||
|
Guid? CategoryId,
|
||||||
|
string? Memo,
|
||||||
|
string? ReferenceNumber,
|
||||||
|
string? CheckNumber,
|
||||||
|
TransactionType Type,
|
||||||
|
List<SplitRequest>? Splits
|
||||||
|
);
|
||||||
|
|
||||||
|
public record UpdateTransactionRequest(
|
||||||
|
DateTime Date,
|
||||||
|
decimal Amount,
|
||||||
|
string? PayeeName,
|
||||||
|
Guid? PayeeId,
|
||||||
|
Guid? CategoryId,
|
||||||
|
string? Memo,
|
||||||
|
string? ReferenceNumber,
|
||||||
|
string? CheckNumber,
|
||||||
|
TransactionType Type,
|
||||||
|
TransactionStatus Status,
|
||||||
|
List<SplitRequest>? Splits
|
||||||
|
);
|
||||||
|
|
||||||
|
public record SplitRequest(Guid? CategoryId, decimal Amount, string? Memo);
|
||||||
|
|
||||||
|
public record TransactionResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid AccountId,
|
||||||
|
string? AccountName,
|
||||||
|
DateTime Date,
|
||||||
|
decimal Amount,
|
||||||
|
Guid? PayeeId,
|
||||||
|
string? PayeeName,
|
||||||
|
Guid? CategoryId,
|
||||||
|
string? CategoryName,
|
||||||
|
string? Memo,
|
||||||
|
string? ReferenceNumber,
|
||||||
|
string? CheckNumber,
|
||||||
|
TransactionType Type,
|
||||||
|
TransactionStatus Status,
|
||||||
|
bool IsVoid,
|
||||||
|
Guid? TransferTransactionId,
|
||||||
|
Guid? ImportBatchId,
|
||||||
|
List<SplitResponse>? Splits,
|
||||||
|
DateTime CreatedAt
|
||||||
|
);
|
||||||
|
|
||||||
|
public record SplitResponse(Guid Id, Guid? CategoryId, string? CategoryName, decimal Amount, string? Memo);
|
||||||
|
|
||||||
|
public record CreateTransferRequest(
|
||||||
|
Guid FromAccountId,
|
||||||
|
Guid ToAccountId,
|
||||||
|
DateTime Date,
|
||||||
|
decimal Amount,
|
||||||
|
string? Memo
|
||||||
|
);
|
||||||
|
|
||||||
|
public record TransactionSearchRequest(
|
||||||
|
Guid? AccountId,
|
||||||
|
DateTime? StartDate,
|
||||||
|
DateTime? EndDate,
|
||||||
|
decimal? MinAmount,
|
||||||
|
decimal? MaxAmount,
|
||||||
|
string? PayeeName,
|
||||||
|
Guid? CategoryId,
|
||||||
|
TransactionStatus? Status,
|
||||||
|
string? SearchText,
|
||||||
|
int Page = 1,
|
||||||
|
int PageSize = 50
|
||||||
|
);
|
||||||
|
|
||||||
|
public record PagedResult<T>(List<T> Items, int TotalCount, int Page, int PageSize, int TotalPages);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace Purrse.Core.Enums;
|
||||||
|
|
||||||
|
public enum AccountType
|
||||||
|
{
|
||||||
|
Checking,
|
||||||
|
Savings,
|
||||||
|
CreditCard,
|
||||||
|
AutoLoan,
|
||||||
|
PersonalLoan,
|
||||||
|
Mortgage,
|
||||||
|
LineOfCredit,
|
||||||
|
Retirement401k,
|
||||||
|
IRA,
|
||||||
|
Brokerage,
|
||||||
|
Cash
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Purrse.Core.Enums;
|
||||||
|
|
||||||
|
public enum CategoryType
|
||||||
|
{
|
||||||
|
Income,
|
||||||
|
Expense,
|
||||||
|
Transfer
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Purrse.Core.Enums;
|
||||||
|
|
||||||
|
public enum Frequency
|
||||||
|
{
|
||||||
|
Daily,
|
||||||
|
Weekly,
|
||||||
|
BiWeekly,
|
||||||
|
SemiMonthly,
|
||||||
|
Monthly,
|
||||||
|
Quarterly,
|
||||||
|
SemiAnnually,
|
||||||
|
Annually
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Purrse.Core.Enums;
|
||||||
|
|
||||||
|
public enum ImportStatus
|
||||||
|
{
|
||||||
|
Pending,
|
||||||
|
Confirmed,
|
||||||
|
Rejected
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Purrse.Core.Enums;
|
||||||
|
|
||||||
|
public enum TransactionStatus
|
||||||
|
{
|
||||||
|
Uncleared,
|
||||||
|
Cleared,
|
||||||
|
Reconciled
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Purrse.Core.Enums;
|
||||||
|
|
||||||
|
public enum TransactionType
|
||||||
|
{
|
||||||
|
Debit,
|
||||||
|
Credit,
|
||||||
|
Transfer
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using F23.StringSimilarity;
|
||||||
|
|
||||||
|
namespace Purrse.Core.Helpers;
|
||||||
|
|
||||||
|
public static class StringSimilarityHelper
|
||||||
|
{
|
||||||
|
private static readonly JaroWinkler _jaroWinkler = new();
|
||||||
|
|
||||||
|
public static double Calculate(string s1, string s2)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(s1) || string.IsNullOrEmpty(s2))
|
||||||
|
return 0;
|
||||||
|
return _jaroWinkler.Similarity(s1.ToUpperInvariant(), s2.ToUpperInvariant());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Purrse.Core.Helpers;
|
||||||
|
|
||||||
|
public static class TransactionFingerprint
|
||||||
|
{
|
||||||
|
public static string Generate(Guid accountId, DateTime date, decimal amount, string? fitId)
|
||||||
|
{
|
||||||
|
var input = $"{accountId}|{date:yyyyMMdd}|{amount:F2}|{fitId ?? ""}";
|
||||||
|
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||||
|
return Convert.ToHexStringLower(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using Purrse.Core.DTOs;
|
||||||
|
|
||||||
|
namespace Purrse.Core.Interfaces.Services;
|
||||||
|
|
||||||
|
public interface IAccountService
|
||||||
|
{
|
||||||
|
Task<List<AccountResponse>> GetAllAsync(Guid userId);
|
||||||
|
Task<AccountResponse?> GetByIdAsync(Guid userId, Guid accountId);
|
||||||
|
Task<AccountResponse> CreateAsync(Guid userId, CreateAccountRequest request);
|
||||||
|
Task<AccountResponse> UpdateAsync(Guid userId, Guid accountId, UpdateAccountRequest request);
|
||||||
|
Task DeleteAsync(Guid userId, Guid accountId);
|
||||||
|
Task<List<BalanceHistoryEntry>> GetBalanceHistoryAsync(Guid userId, Guid accountId, DateTime startDate, DateTime endDate);
|
||||||
|
Task RecalculateBalanceAsync(Guid accountId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Purrse.Core.DTOs;
|
||||||
|
|
||||||
|
namespace Purrse.Core.Interfaces.Services;
|
||||||
|
|
||||||
|
public interface IAuthService
|
||||||
|
{
|
||||||
|
Task<AuthResponse> RegisterAsync(RegisterRequest request);
|
||||||
|
Task<AuthResponse> LoginAsync(LoginRequest request);
|
||||||
|
Task<AuthResponse> RefreshTokenAsync(string refreshToken);
|
||||||
|
Task RevokeRefreshTokenAsync(Guid userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using Purrse.Core.DTOs;
|
||||||
|
|
||||||
|
namespace Purrse.Core.Interfaces.Services;
|
||||||
|
|
||||||
|
public interface IBudgetService
|
||||||
|
{
|
||||||
|
Task<BudgetResponse?> GetByMonthAsync(Guid userId, int year, int month);
|
||||||
|
Task<BudgetResponse> CreateAsync(Guid userId, CreateBudgetRequest request);
|
||||||
|
Task<BudgetResponse> UpdateAsync(Guid userId, Guid budgetId, UpdateBudgetRequest request);
|
||||||
|
Task DeleteAsync(Guid userId, Guid budgetId);
|
||||||
|
Task<List<BudgetItemResponse>> GetActualsAsync(Guid userId, Guid budgetId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using Purrse.Core.DTOs;
|
||||||
|
|
||||||
|
namespace Purrse.Core.Interfaces.Services;
|
||||||
|
|
||||||
|
public interface ICategoryService
|
||||||
|
{
|
||||||
|
Task<List<CategoryResponse>> GetAllAsync(Guid userId);
|
||||||
|
Task<List<CategoryTreeResponse>> GetTreeAsync(Guid userId);
|
||||||
|
Task<CategoryResponse?> GetByIdAsync(Guid userId, Guid categoryId);
|
||||||
|
Task<CategoryResponse> CreateAsync(Guid userId, CreateCategoryRequest request);
|
||||||
|
Task<CategoryResponse> UpdateAsync(Guid userId, Guid categoryId, UpdateCategoryRequest request);
|
||||||
|
Task DeleteAsync(Guid userId, Guid categoryId);
|
||||||
|
Task SeedDefaultCategoriesAsync(Guid userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using Purrse.Core.DTOs;
|
||||||
|
|
||||||
|
namespace Purrse.Core.Interfaces.Services;
|
||||||
|
|
||||||
|
public interface IDashboardService
|
||||||
|
{
|
||||||
|
Task<DashboardResponse> GetDashboardAsync(Guid userId);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user