commit 6520ebf2212b3d06b555e2e8693498cc79af1cff Author: Catherine Renelle <32781029+catrenelle@users.noreply.github.com> Date: Sun Feb 8 08:56:46 2026 -0500 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4623d21 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +DB_PASSWORD=change_me_in_production +JWT_KEY=YourSuperSecretKeyHere_AtLeast32Characters! diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f84535 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Purrse.sln b/Purrse.sln new file mode 100644 index 0000000..feee7b1 --- /dev/null +++ b/Purrse.sln @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5205f4d --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..a5cf7d4 --- /dev/null +++ b/frontend/Dockerfile @@ -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 diff --git a/frontend/env.d.ts b/frontend/env.d.ts new file mode 100644 index 0000000..323c78a --- /dev/null +++ b/frontend/env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..40554ea --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Purrse - Personal Finance + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..02bbb9b --- /dev/null +++ b/frontend/nginx.conf @@ -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; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..6532257 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..a74573f --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/components/layout/AppLayout.vue b/frontend/src/components/layout/AppLayout.vue new file mode 100644 index 0000000..1d97706 --- /dev/null +++ b/frontend/src/components/layout/AppLayout.vue @@ -0,0 +1,126 @@ + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..cc172a9 --- /dev/null +++ b/frontend/src/main.ts @@ -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') diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..84f936c --- /dev/null +++ b/frontend/src/router/index.ts @@ -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 diff --git a/frontend/src/services/accounts.ts b/frontend/src/services/accounts.ts new file mode 100644 index 0000000..a49e354 --- /dev/null +++ b/frontend/src/services/accounts.ts @@ -0,0 +1,12 @@ +import api from './api' +import type { Account } from '@/types' + +export const accountsApi = { + getAll: () => api.get('/accounts'), + getById: (id: string) => api.get(`/accounts/${id}`), + create: (data: any) => api.post('/accounts', data), + update: (id: string, data: any) => api.put(`/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 } }), +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..f106d86 --- /dev/null +++ b/frontend/src/services/api.ts @@ -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 diff --git a/frontend/src/services/categories.ts b/frontend/src/services/categories.ts new file mode 100644 index 0000000..db79eca --- /dev/null +++ b/frontend/src/services/categories.ts @@ -0,0 +1,10 @@ +import api from './api' +import type { Category, CategoryTree } from '@/types' + +export const categoriesApi = { + getAll: () => api.get('/categories'), + getTree: () => api.get('/categories/tree'), + create: (data: any) => api.post('/categories', data), + update: (id: string, data: any) => api.put(`/categories/${id}`, data), + delete: (id: string) => api.delete(`/categories/${id}`), +} diff --git a/frontend/src/services/transactions.ts b/frontend/src/services/transactions.ts new file mode 100644 index 0000000..a39cfc5 --- /dev/null +++ b/frontend/src/services/transactions.ts @@ -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>(`/transactions/account/${accountId}`, { params: { page, pageSize } }), + getById: (id: string) => api.get(`/transactions/${id}`), + create: (data: any) => api.post('/transactions', data), + update: (id: string, data: any) => api.put(`/transactions/${id}`, data), + delete: (id: string) => api.delete(`/transactions/${id}`), + search: (data: any) => api.post>('/transactions/search', data), + createTransfer: (data: any) => api.post('/transactions/transfer', data), + void: (id: string) => api.post(`/transactions/${id}/void`), +} diff --git a/frontend/src/stores/accounts.ts b/frontend/src/stores/accounts.ts new file mode 100644 index 0000000..d5464d3 --- /dev/null +++ b/frontend/src/stores/accounts.ts @@ -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([]) + 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 } +}) diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..3a5a8a8 --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -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(null) + const refreshTokenValue = ref(null) + const username = ref(null) + const expiresAt = ref(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('/auth/login', { username: usernameInput, password }) + setAuth(data) + } + + async function register(usernameInput: string, email: string, password: string) { + const { data } = await api.post('/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('/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 } +}) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..88430e4 --- /dev/null +++ b/frontend/src/types/index.ts @@ -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 { + 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 +} diff --git a/frontend/src/utils/formatters.ts b/frontend/src/utils/formatters.ts new file mode 100644 index 0000000..995e23b --- /dev/null +++ b/frontend/src/utils/formatters.ts @@ -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 = { + 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 = { + 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' +} diff --git a/frontend/src/views/accounts/AccountsView.vue b/frontend/src/views/accounts/AccountsView.vue new file mode 100644 index 0000000..23630eb --- /dev/null +++ b/frontend/src/views/accounts/AccountsView.vue @@ -0,0 +1,129 @@ + + + diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue new file mode 100644 index 0000000..e11f19f --- /dev/null +++ b/frontend/src/views/auth/LoginView.vue @@ -0,0 +1,51 @@ + + + diff --git a/frontend/src/views/auth/RegisterView.vue b/frontend/src/views/auth/RegisterView.vue new file mode 100644 index 0000000..5fa7f01 --- /dev/null +++ b/frontend/src/views/auth/RegisterView.vue @@ -0,0 +1,59 @@ + + + diff --git a/frontend/src/views/budgets/BudgetsView.vue b/frontend/src/views/budgets/BudgetsView.vue new file mode 100644 index 0000000..de4ca5b --- /dev/null +++ b/frontend/src/views/budgets/BudgetsView.vue @@ -0,0 +1,28 @@ + + + diff --git a/frontend/src/views/categories/CategoriesView.vue b/frontend/src/views/categories/CategoriesView.vue new file mode 100644 index 0000000..72eb924 --- /dev/null +++ b/frontend/src/views/categories/CategoriesView.vue @@ -0,0 +1,62 @@ + + + diff --git a/frontend/src/views/dashboard/DashboardView.vue b/frontend/src/views/dashboard/DashboardView.vue new file mode 100644 index 0000000..7ae19cc --- /dev/null +++ b/frontend/src/views/dashboard/DashboardView.vue @@ -0,0 +1,126 @@ + + + diff --git a/frontend/src/views/investments/InvestmentsView.vue b/frontend/src/views/investments/InvestmentsView.vue new file mode 100644 index 0000000..d345951 --- /dev/null +++ b/frontend/src/views/investments/InvestmentsView.vue @@ -0,0 +1,6 @@ + diff --git a/frontend/src/views/loans/LoansView.vue b/frontend/src/views/loans/LoansView.vue new file mode 100644 index 0000000..98a8237 --- /dev/null +++ b/frontend/src/views/loans/LoansView.vue @@ -0,0 +1,10 @@ + + + diff --git a/frontend/src/views/reports/ReportsView.vue b/frontend/src/views/reports/ReportsView.vue new file mode 100644 index 0000000..860b8dd --- /dev/null +++ b/frontend/src/views/reports/ReportsView.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/views/scheduled/ScheduledView.vue b/frontend/src/views/scheduled/ScheduledView.vue new file mode 100644 index 0000000..6e230b8 --- /dev/null +++ b/frontend/src/views/scheduled/ScheduledView.vue @@ -0,0 +1,6 @@ + diff --git a/frontend/src/views/transactions/TransactionsView.vue b/frontend/src/views/transactions/TransactionsView.vue new file mode 100644 index 0000000..d689e5b --- /dev/null +++ b/frontend/src/views/transactions/TransactionsView.vue @@ -0,0 +1,193 @@ + + + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..151612a --- /dev/null +++ b/frontend/tsconfig.json @@ -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" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..d099f78 --- /dev/null +++ b/frontend/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 + } + } + } +}) diff --git a/src/Purrse.Api/Controllers/AccountsController.cs b/src/Purrse.Api/Controllers/AccountsController.cs new file mode 100644 index 0000000..9940c19 --- /dev/null +++ b/src/Purrse.Api/Controllers/AccountsController.cs @@ -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>> GetAll() + => Ok(await _accountService.GetAllAsync(UserId)); + + [HttpGet("{id}")] + public async Task> GetById(Guid id) + { + var account = await _accountService.GetByIdAsync(UserId, id); + return account == null ? NotFound() : Ok(account); + } + + [HttpPost] + public async Task> Create(CreateAccountRequest request) + { + var account = await _accountService.CreateAsync(UserId, request); + return CreatedAtAction(nameof(GetById), new { id = account.Id }, account); + } + + [HttpPut("{id}")] + public async Task> Update(Guid id, UpdateAccountRequest request) + => Ok(await _accountService.UpdateAsync(UserId, id, request)); + + [HttpDelete("{id}")] + public async Task Delete(Guid id) + { + await _accountService.DeleteAsync(UserId, id); + return NoContent(); + } + + [HttpGet("{id}/balance-history")] + public async Task>> GetBalanceHistory(Guid id, [FromQuery] DateTime startDate, [FromQuery] DateTime endDate) + => Ok(await _accountService.GetBalanceHistoryAsync(UserId, id, startDate, endDate)); +} diff --git a/src/Purrse.Api/Controllers/AuthController.cs b/src/Purrse.Api/Controllers/AuthController.cs new file mode 100644 index 0000000..745e30d --- /dev/null +++ b/src/Purrse.Api/Controllers/AuthController.cs @@ -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> Register(RegisterRequest request) + { + var result = await _authService.RegisterAsync(request); + return Ok(result); + } + + [HttpPost("login")] + public async Task> Login(LoginRequest request) + { + var result = await _authService.LoginAsync(request); + return Ok(result); + } + + [HttpPost("refresh")] + public async Task> Refresh(RefreshRequest request) + { + var result = await _authService.RefreshTokenAsync(request.RefreshToken); + return Ok(result); + } + + [Authorize] + [HttpPost("revoke")] + public async Task Revoke() + { + var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + await _authService.RevokeRefreshTokenAsync(userId); + return NoContent(); + } +} diff --git a/src/Purrse.Api/Controllers/BudgetsController.cs b/src/Purrse.Api/Controllers/BudgetsController.cs new file mode 100644 index 0000000..1962a70 --- /dev/null +++ b/src/Purrse.Api/Controllers/BudgetsController.cs @@ -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> GetByMonth(int year, int month) + { + var budget = await _budgetService.GetByMonthAsync(UserId, year, month); + return budget == null ? NotFound() : Ok(budget); + } + + [HttpPost] + public async Task> Create(CreateBudgetRequest request) + => Ok(await _budgetService.CreateAsync(UserId, request)); + + [HttpPut("{id}")] + public async Task> Update(Guid id, UpdateBudgetRequest request) + => Ok(await _budgetService.UpdateAsync(UserId, id, request)); + + [HttpDelete("{id}")] + public async Task Delete(Guid id) + { + await _budgetService.DeleteAsync(UserId, id); + return NoContent(); + } + + [HttpGet("{id}/actual")] + public async Task>> GetActuals(Guid id) + => Ok(await _budgetService.GetActualsAsync(UserId, id)); +} diff --git a/src/Purrse.Api/Controllers/CategoriesController.cs b/src/Purrse.Api/Controllers/CategoriesController.cs new file mode 100644 index 0000000..f026162 --- /dev/null +++ b/src/Purrse.Api/Controllers/CategoriesController.cs @@ -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>> GetAll() + => Ok(await _categoryService.GetAllAsync(UserId)); + + [HttpGet("tree")] + public async Task>> GetTree() + => Ok(await _categoryService.GetTreeAsync(UserId)); + + [HttpGet("{id}")] + public async Task> GetById(Guid id) + { + var cat = await _categoryService.GetByIdAsync(UserId, id); + return cat == null ? NotFound() : Ok(cat); + } + + [HttpPost] + public async Task> Create(CreateCategoryRequest request) + { + var cat = await _categoryService.CreateAsync(UserId, request); + return CreatedAtAction(nameof(GetById), new { id = cat.Id }, cat); + } + + [HttpPut("{id}")] + public async Task> Update(Guid id, UpdateCategoryRequest request) + => Ok(await _categoryService.UpdateAsync(UserId, id, request)); + + [HttpDelete("{id}")] + public async Task Delete(Guid id) + { + await _categoryService.DeleteAsync(UserId, id); + return NoContent(); + } +} diff --git a/src/Purrse.Api/Controllers/DashboardController.cs b/src/Purrse.Api/Controllers/DashboardController.cs new file mode 100644 index 0000000..8cccce0 --- /dev/null +++ b/src/Purrse.Api/Controllers/DashboardController.cs @@ -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> Get() + => Ok(await _dashboardService.GetDashboardAsync(UserId)); +} diff --git a/src/Purrse.Api/Controllers/ImportsController.cs b/src/Purrse.Api/Controllers/ImportsController.cs new file mode 100644 index 0000000..8056c09 --- /dev/null +++ b/src/Purrse.Api/Controllers/ImportsController.cs @@ -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> 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> ConfirmBatch(Guid batchId) + => Ok(await _importService.ConfirmBatchAsync(UserId, batchId)); + + [HttpPost("batches/{batchId}/resolve-duplicates")] + public async Task ResolveDuplicates(Guid batchId, ResolveDuplicatesRequest request) + { + await _importService.ResolveDuplicatesAsync(UserId, batchId, request); + return NoContent(); + } +} diff --git a/src/Purrse.Api/Controllers/InvestmentsController.cs b/src/Purrse.Api/Controllers/InvestmentsController.cs new file mode 100644 index 0000000..5a13771 --- /dev/null +++ b/src/Purrse.Api/Controllers/InvestmentsController.cs @@ -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>> 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> GetPerformance(Guid accountId) + { + var holdingsResult = await GetHoldings(accountId); + var holdings = (holdingsResult.Result as OkObjectResult)?.Value as List ?? 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)); + } +} diff --git a/src/Purrse.Api/Controllers/LoansController.cs b/src/Purrse.Api/Controllers/LoansController.cs new file mode 100644 index 0000000..87080f4 --- /dev/null +++ b/src/Purrse.Api/Controllers/LoansController.cs @@ -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> GetLoanDetail(Guid accountId) + { + var detail = await _loanService.GetLoanDetailAsync(UserId, accountId); + return detail == null ? NotFound() : Ok(detail); + } + + [HttpPost("{accountId}")] + public async Task> CreateLoanDetail(Guid accountId, CreateLoanDetailRequest request) + => Ok(await _loanService.CreateLoanDetailAsync(UserId, accountId, request)); + + [HttpPut("{accountId}")] + public async Task> UpdateLoanDetail(Guid accountId, CreateLoanDetailRequest request) + => Ok(await _loanService.UpdateLoanDetailAsync(UserId, accountId, request)); + + [HttpPost("{accountId}/payoff-scenario")] + public async Task> PayoffScenario(Guid accountId, PayoffScenarioRequest request) + => Ok(await _loanService.CalculatePayoffScenarioAsync(UserId, accountId, request)); +} diff --git a/src/Purrse.Api/Controllers/PayeesController.cs b/src/Purrse.Api/Controllers/PayeesController.cs new file mode 100644 index 0000000..ff7eb9a --- /dev/null +++ b/src/Purrse.Api/Controllers/PayeesController.cs @@ -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>> GetAll() + => Ok(await _payeeService.GetAllAsync(UserId)); + + [HttpGet("{id}")] + public async Task> GetById(Guid id) + { + var payee = await _payeeService.GetByIdAsync(UserId, id); + return payee == null ? NotFound() : Ok(payee); + } + + [HttpPost] + public async Task> Create(CreatePayeeRequest request) + { + var payee = await _payeeService.CreateAsync(UserId, request); + return CreatedAtAction(nameof(GetById), new { id = payee.Id }, payee); + } + + [HttpPut("{id}")] + public async Task> Update(Guid id, UpdatePayeeRequest request) + => Ok(await _payeeService.UpdateAsync(UserId, id, request)); + + [HttpDelete("{id}")] + public async Task Delete(Guid id) + { + await _payeeService.DeleteAsync(UserId, id); + return NoContent(); + } + + [HttpPost("{id}/aliases")] + public async Task AddAlias(Guid id, CreatePayeeAliasRequest request) + { + await _payeeService.AddAliasAsync(UserId, id, request.Alias); + return NoContent(); + } + + [HttpDelete("{id}/aliases/{aliasId}")] + public async Task RemoveAlias(Guid id, Guid aliasId) + { + await _payeeService.RemoveAliasAsync(UserId, id, aliasId); + return NoContent(); + } +} diff --git a/src/Purrse.Api/Controllers/PluginsController.cs b/src/Purrse.Api/Controllers/PluginsController.cs new file mode 100644 index 0000000..98dc1a5 --- /dev/null +++ b/src/Purrse.Api/Controllers/PluginsController.cs @@ -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); + } +} diff --git a/src/Purrse.Api/Controllers/ReconciliationController.cs b/src/Purrse.Api/Controllers/ReconciliationController.cs new file mode 100644 index 0000000..6083f99 --- /dev/null +++ b/src/Purrse.Api/Controllers/ReconciliationController.cs @@ -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> 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 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 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(); + } +} diff --git a/src/Purrse.Api/Controllers/ReportsController.cs b/src/Purrse.Api/Controllers/ReportsController.cs new file mode 100644 index 0000000..60fdc67 --- /dev/null +++ b/src/Purrse.Api/Controllers/ReportsController.cs @@ -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> 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> IncomeVsExpense([FromQuery] DateTime startDate, [FromQuery] DateTime endDate) + => Ok(await _reportService.GetIncomeVsExpenseAsync(UserId, startDate, endDate)); + + [HttpGet("net-worth")] + public async Task> NetWorth([FromQuery] DateTime startDate, [FromQuery] DateTime endDate) + => Ok(await _reportService.GetNetWorthAsync(UserId, startDate, endDate)); + + [HttpGet("cash-flow")] + public async Task> CashFlow([FromQuery] DateTime startDate, [FromQuery] DateTime endDate) + => Ok(await _reportService.GetCashFlowAsync(UserId, startDate, endDate)); +} diff --git a/src/Purrse.Api/Controllers/ScheduledTransactionsController.cs b/src/Purrse.Api/Controllers/ScheduledTransactionsController.cs new file mode 100644 index 0000000..ad02951 --- /dev/null +++ b/src/Purrse.Api/Controllers/ScheduledTransactionsController.cs @@ -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>> GetAll() + => Ok(await _service.GetAllAsync(UserId)); + + [HttpGet("{id}")] + public async Task> GetById(Guid id) + { + var s = await _service.GetByIdAsync(UserId, id); + return s == null ? NotFound() : Ok(s); + } + + [HttpPost] + public async Task> Create(CreateScheduledTransactionRequest request) + => Ok(await _service.CreateAsync(UserId, request)); + + [HttpPut("{id}")] + public async Task> Update(Guid id, UpdateScheduledTransactionRequest request) + => Ok(await _service.UpdateAsync(UserId, id, request)); + + [HttpDelete("{id}")] + public async Task Delete(Guid id) + { + await _service.DeleteAsync(UserId, id); + return NoContent(); + } + + [HttpPost("{id}/skip")] + public async Task Skip(Guid id) + { + await _service.SkipAsync(UserId, id); + return NoContent(); + } + + [HttpPost("{id}/post-now")] + public async Task> PostNow(Guid id) + => Ok(await _service.PostNowAsync(UserId, id)); +} diff --git a/src/Purrse.Api/Controllers/TransactionsController.cs b/src/Purrse.Api/Controllers/TransactionsController.cs new file mode 100644 index 0000000..b48dde5 --- /dev/null +++ b/src/Purrse.Api/Controllers/TransactionsController.cs @@ -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>> GetByAccount(Guid accountId, [FromQuery] int page = 1, [FromQuery] int pageSize = 50) + => Ok(await _transactionService.GetByAccountAsync(UserId, accountId, page, pageSize)); + + [HttpGet("{id}")] + public async Task> GetById(Guid id) + { + var txn = await _transactionService.GetByIdAsync(UserId, id); + return txn == null ? NotFound() : Ok(txn); + } + + [HttpPost] + public async Task> Create(CreateTransactionRequest request) + { + var txn = await _transactionService.CreateAsync(UserId, request); + return CreatedAtAction(nameof(GetById), new { id = txn.Id }, txn); + } + + [HttpPut("{id}")] + public async Task> Update(Guid id, UpdateTransactionRequest request) + => Ok(await _transactionService.UpdateAsync(UserId, id, request)); + + [HttpDelete("{id}")] + public async Task Delete(Guid id) + { + await _transactionService.DeleteAsync(UserId, id); + return NoContent(); + } + + [HttpPost("search")] + public async Task>> Search(TransactionSearchRequest request) + => Ok(await _transactionService.SearchAsync(UserId, request)); + + [HttpPost("transfer")] + public async Task CreateTransfer(CreateTransferRequest request) + { + var (from, to) = await _transactionService.CreateTransferAsync(UserId, request); + return Ok(new { from, to }); + } + + [HttpPost("{id}/void")] + public async Task Void(Guid id) + { + await _transactionService.VoidAsync(UserId, id); + return NoContent(); + } +} diff --git a/src/Purrse.Api/Dockerfile b/src/Purrse.Api/Dockerfile new file mode 100644 index 0000000..3050eae --- /dev/null +++ b/src/Purrse.Api/Dockerfile @@ -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"] diff --git a/src/Purrse.Api/Hubs/NotificationHub.cs b/src/Purrse.Api/Hubs/NotificationHub.cs new file mode 100644 index 0000000..b947cfa --- /dev/null +++ b/src/Purrse.Api/Hubs/NotificationHub.cs @@ -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); + } +} diff --git a/src/Purrse.Api/Middleware/ExceptionHandlingMiddleware.cs b/src/Purrse.Api/Middleware/ExceptionHandlingMiddleware.cs new file mode 100644 index 0000000..7643e58 --- /dev/null +++ b/src/Purrse.Api/Middleware/ExceptionHandlingMiddleware.cs @@ -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 _logger; + + public ExceptionHandlingMiddleware(RequestDelegate next, ILogger 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); + } +} diff --git a/src/Purrse.Api/Middleware/RequestLoggingMiddleware.cs b/src/Purrse.Api/Middleware/RequestLoggingMiddleware.cs new file mode 100644 index 0000000..b27406b --- /dev/null +++ b/src/Purrse.Api/Middleware/RequestLoggingMiddleware.cs @@ -0,0 +1,25 @@ +using System.Diagnostics; + +namespace Purrse.Api.Middleware; + +public class RequestLoggingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public RequestLoggingMiddleware(RequestDelegate next, ILogger 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); + } +} diff --git a/src/Purrse.Api/Program.cs b/src/Purrse.Api/Program.cs new file mode 100644 index 0000000..2f4b6cb --- /dev/null +++ b/src/Purrse.Api/Program.cs @@ -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(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() ?? new[] { "http://localhost:5173", "http://localhost:8080" }) + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials(); + }); +}); + +// Services +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Built-in file parsers +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// Plugin system +builder.Services.AddSingleton(); + +// Background services +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); + +// 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(); + await db.Database.MigrateAsync(); +} + +// Initialize plugin system +var pluginService = app.Services.GetRequiredService(); +await pluginService.DiscoverAndLoadPluginsAsync(); + +// Middleware +app.UseMiddleware(); +app.UseMiddleware(); + +app.UseCors(); +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapControllers(); +app.MapHub("/hubs/notifications"); + +app.Run(); diff --git a/src/Purrse.Api/Properties/launchSettings.json b/src/Purrse.Api/Properties/launchSettings.json new file mode 100644 index 0000000..6fa8e73 --- /dev/null +++ b/src/Purrse.Api/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/src/Purrse.Api/Purrse.Api.csproj b/src/Purrse.Api/Purrse.Api.csproj new file mode 100644 index 0000000..060451b --- /dev/null +++ b/src/Purrse.Api/Purrse.Api.csproj @@ -0,0 +1,27 @@ + + + + net9.0 + enable + enable + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + diff --git a/src/Purrse.Api/Services/AccountService.cs b/src/Purrse.Api/Services/AccountService.cs new file mode 100644 index 0000000..09d6bb6 --- /dev/null +++ b/src/Purrse.Api/Services/AccountService.cs @@ -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> 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 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 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 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> 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(); + + 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 + ); +} diff --git a/src/Purrse.Api/Services/AuthService.cs b/src/Purrse.Api/Services/AuthService.cs new file mode 100644 index 0000000..3d348b7 --- /dev/null +++ b/src/Purrse.Api/Services/AuthService.cs @@ -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 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 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 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); + } +} diff --git a/src/Purrse.Api/Services/BudgetService.cs b/src/Purrse.Api/Services/BudgetService.cs new file mode 100644 index 0000000..42d80f8 --- /dev/null +++ b/src/Purrse.Api/Services/BudgetService.cs @@ -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 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 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()); + } + + public async Task 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> 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> 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 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 + ); +} diff --git a/src/Purrse.Api/Services/CategoryService.cs b/src/Purrse.Api/Services/CategoryService.cs new file mode 100644 index 0000000..c862ee5 --- /dev/null +++ b/src/Purrse.Api/Services/CategoryService.cs @@ -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> 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> 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 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 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 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 BuildTree(List 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(); + } +} diff --git a/src/Purrse.Api/Services/DashboardService.cs b/src/Purrse.Api/Services/DashboardService.cs new file mode 100644 index 0000000..a8b8a3e --- /dev/null +++ b/src/Purrse.Api/Services/DashboardService.cs @@ -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 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); + } +} diff --git a/src/Purrse.Api/Services/DuplicateDetectionService.cs b/src/Purrse.Api/Services/DuplicateDetectionService.cs new file mode 100644 index 0000000..eaa8a1f --- /dev/null +++ b/src/Purrse.Api/Services/DuplicateDetectionService.cs @@ -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 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(); + 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; + } +} diff --git a/src/Purrse.Api/Services/FileWatcherService.cs b/src/Purrse.Api/Services/FileWatcherService.cs new file mode 100644 index 0000000..6d1669a --- /dev/null +++ b/src/Purrse.Api/Services/FileWatcherService.cs @@ -0,0 +1,47 @@ +namespace Purrse.Api.Services; + +public class FileWatcherService : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private readonly string _importsPath; + private FileSystemWatcher? _watcher; + + public FileWatcherService(IServiceScopeFactory scopeFactory, ILogger 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 + } +} diff --git a/src/Purrse.Api/Services/ImportService.cs b/src/Purrse.Api/Services/ImportService.cs new file mode 100644 index 0000000..81073d2 --- /dev/null +++ b/src/Purrse.Api/Services/ImportService.cs @@ -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 _parsers; + + public ImportService(PurrseDbContext db, IDuplicateDetectionService duplicateService, IPayeeService payeeService, IEnumerable parsers) + { + _db = db; + _duplicateService = duplicateService; + _payeeService = payeeService; + _parsers = parsers; + } + + public async Task 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(); + + 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 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(); + } +} diff --git a/src/Purrse.Api/Services/LoanService.cs b/src/Purrse.Api/Services/LoanService.cs new file mode 100644 index 0000000..af01581 --- /dev/null +++ b/src/Purrse.Api/Services/LoanService.cs @@ -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 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 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 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> GetAmortizationScheduleAsync(Guid userId, Guid accountId) + { + var detail = await GetLoanDetailAsync(userId, accountId) + ?? throw new KeyNotFoundException("Loan detail not found"); + return detail.AmortizationSchedule; + } + + public async Task 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(); + 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 GenerateAmortizationSchedule(LoanDetail loan) + { + var entries = new List(); + 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; + } +} diff --git a/src/Purrse.Api/Services/PayeeService.cs b/src/Purrse.Api/Services/PayeeService.cs new file mode 100644 index 0000000..d444d6f --- /dev/null +++ b/src/Purrse.Api/Services/PayeeService.cs @@ -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> 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 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 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 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 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; + } +} diff --git a/src/Purrse.Api/Services/PluginService.cs b/src/Purrse.Api/Services/PluginService.cs new file mode 100644 index 0000000..33bd8e7 --- /dev/null +++ b/src/Purrse.Api/Services/PluginService.cs @@ -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 _logger; + private readonly string _pluginsPath; + private readonly Dictionary _loadedPlugins = new(); + + public PluginService(ILogger logger, IConfiguration config) + { + _logger = logger; + _pluginsPath = config["Plugins:Path"] ?? Path.Combine(AppContext.BaseDirectory, "plugins"); + } + + public IEnumerable GetPlugins() => _loadedPlugins.Values.Select(p => p.Instance); + public IEnumerable GetPlugins() where T : IPlugin => _loadedPlugins.Values.Select(p => p.Instance).OfType(); + + 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(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}"); + } +} diff --git a/src/Purrse.Api/Services/ReportService.cs b/src/Purrse.Api/Services/ReportService.cs new file mode 100644 index 0000000..e7c447f --- /dev/null +++ b/src/Purrse.Api/Services/ReportService.cs @@ -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 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 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 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(); + 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 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); + } +} diff --git a/src/Purrse.Api/Services/ScheduledTransactionProcessor.cs b/src/Purrse.Api/Services/ScheduledTransactionProcessor.cs new file mode 100644 index 0000000..f367f39 --- /dev/null +++ b/src/Purrse.Api/Services/ScheduledTransactionProcessor.cs @@ -0,0 +1,34 @@ +using Purrse.Core.Interfaces.Services; + +namespace Purrse.Api.Services; + +public class ScheduledTransactionProcessor : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public ScheduledTransactionProcessor(IServiceScopeFactory scopeFactory, ILogger 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(); + await service.ProcessDueTransactionsAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing scheduled transactions"); + } + + await Task.Delay(TimeSpan.FromHours(1), stoppingToken); + } + } +} diff --git a/src/Purrse.Api/Services/ScheduledTransactionService.cs b/src/Purrse.Api/Services/ScheduledTransactionService.cs new file mode 100644 index 0000000..7231b92 --- /dev/null +++ b/src/Purrse.Api/Services/ScheduledTransactionService.cs @@ -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> 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 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 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 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 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); +} diff --git a/src/Purrse.Api/Services/TransactionService.cs b/src/Purrse.Api/Services/TransactionService.cs new file mode 100644 index 0000000..82883ef --- /dev/null +++ b/src/Purrse.Api/Services/TransactionService.cs @@ -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> 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 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 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 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> 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> PaginateAsync(IQueryable 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( + 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 + ); +} diff --git a/src/Purrse.Api/appsettings.Development.json b/src/Purrse.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/Purrse.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Purrse.Api/appsettings.json b/src/Purrse.Api/appsettings.json new file mode 100644 index 0000000..ca4ba78 --- /dev/null +++ b/src/Purrse.Api/appsettings.json @@ -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" + } + } +} diff --git a/src/Purrse.Core/DTOs/AccountDtos.cs b/src/Purrse.Core/DTOs/AccountDtos.cs new file mode 100644 index 0000000..47f711d --- /dev/null +++ b/src/Purrse.Core/DTOs/AccountDtos.cs @@ -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); diff --git a/src/Purrse.Core/DTOs/AuthDtos.cs b/src/Purrse.Core/DTOs/AuthDtos.cs new file mode 100644 index 0000000..180793a --- /dev/null +++ b/src/Purrse.Core/DTOs/AuthDtos.cs @@ -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); diff --git a/src/Purrse.Core/DTOs/BudgetDtos.cs b/src/Purrse.Core/DTOs/BudgetDtos.cs new file mode 100644 index 0000000..7e6c151 --- /dev/null +++ b/src/Purrse.Core/DTOs/BudgetDtos.cs @@ -0,0 +1,22 @@ +namespace Purrse.Core.DTOs; + +public record CreateBudgetRequest(int Year, int Month, string? Notes, List Items); +public record UpdateBudgetRequest(string? Notes, List Items); +public record BudgetItemRequest(Guid CategoryId, decimal BudgetedAmount); + +public record BudgetResponse( + Guid Id, + int Year, + int Month, + string? Notes, + List Items, + DateTime CreatedAt +); + +public record BudgetItemResponse( + Guid Id, + Guid CategoryId, + string CategoryName, + decimal BudgetedAmount, + decimal ActualAmount +); diff --git a/src/Purrse.Core/DTOs/CategoryDtos.cs b/src/Purrse.Core/DTOs/CategoryDtos.cs new file mode 100644 index 0000000..808c4d6 --- /dev/null +++ b/src/Purrse.Core/DTOs/CategoryDtos.cs @@ -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 Children +); diff --git a/src/Purrse.Core/DTOs/DashboardDtos.cs b/src/Purrse.Core/DTOs/DashboardDtos.cs new file mode 100644 index 0000000..806eb99 --- /dev/null +++ b/src/Purrse.Core/DTOs/DashboardDtos.cs @@ -0,0 +1,27 @@ +namespace Purrse.Core.DTOs; + +public record DashboardResponse( + decimal NetWorth, + decimal TotalAssets, + decimal TotalLiabilities, + List Accounts, + List RecentTransactions, + SpendingSummary SpendingSummary, + List UpcomingBills +); + +public record SpendingSummary( + decimal ThisMonth, + decimal LastMonth, + decimal ChangePercent, + List TopCategories +); + +public record UpcomingBill( + Guid Id, + string PayeeName, + decimal Amount, + DateTime DueDate, + string AccountName, + bool AutoPost +); diff --git a/src/Purrse.Core/DTOs/ImportDtos.cs b/src/Purrse.Core/DTOs/ImportDtos.cs new file mode 100644 index 0000000..5c1189d --- /dev/null +++ b/src/Purrse.Core/DTOs/ImportDtos.cs @@ -0,0 +1,38 @@ +using Purrse.Core.Enums; + +namespace Purrse.Core.DTOs; + +public record ImportUploadResponse( + Guid BatchId, + int TotalTransactions, + int NewTransactions, + int PossibleDuplicates, + List 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 Resolutions); + +public record DuplicateResolution(int Index, DuplicateAction Action); + +public enum DuplicateAction +{ + Import, + Skip, + Merge +} diff --git a/src/Purrse.Core/DTOs/InvestmentDtos.cs b/src/Purrse.Core/DTOs/InvestmentDtos.cs new file mode 100644 index 0000000..90676e1 --- /dev/null +++ b/src/Purrse.Core/DTOs/InvestmentDtos.cs @@ -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 Holdings +); diff --git a/src/Purrse.Core/DTOs/LoanDtos.cs b/src/Purrse.Core/DTOs/LoanDtos.cs new file mode 100644 index 0000000..1258b6b --- /dev/null +++ b/src/Purrse.Core/DTOs/LoanDtos.cs @@ -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 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 NewSchedule +); diff --git a/src/Purrse.Core/DTOs/PayeeDtos.cs b/src/Purrse.Core/DTOs/PayeeDtos.cs new file mode 100644 index 0000000..ed9645f --- /dev/null +++ b/src/Purrse.Core/DTOs/PayeeDtos.cs @@ -0,0 +1,15 @@ +namespace Purrse.Core.DTOs; + +public record CreatePayeeRequest(string Name, Guid? DefaultCategoryId, List? Aliases); +public record UpdatePayeeRequest(string Name, Guid? DefaultCategoryId, bool IsActive); + +public record PayeeResponse( + Guid Id, + string Name, + Guid? DefaultCategoryId, + string? DefaultCategoryName, + bool IsActive, + List Aliases +); + +public record CreatePayeeAliasRequest(string Alias); diff --git a/src/Purrse.Core/DTOs/PluginDtos.cs b/src/Purrse.Core/DTOs/PluginDtos.cs new file mode 100644 index 0000000..0c54d6a --- /dev/null +++ b/src/Purrse.Core/DTOs/PluginDtos.cs @@ -0,0 +1,14 @@ +namespace Purrse.Core.DTOs; + +public record PluginResponse( + Guid Id, + string PluginId, + string Name, + string Version, + bool IsEnabled, + DateTime InstalledAt, + List Capabilities, + Dictionary Configuration +); + +public record PluginConfigurationRequest(Dictionary Settings); diff --git a/src/Purrse.Core/DTOs/ReconciliationDtos.cs b/src/Purrse.Core/DTOs/ReconciliationDtos.cs new file mode 100644 index 0000000..53988de --- /dev/null +++ b/src/Purrse.Core/DTOs/ReconciliationDtos.cs @@ -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 UnclearedTransactions +); diff --git a/src/Purrse.Core/DTOs/ReportDtos.cs b/src/Purrse.Core/DTOs/ReportDtos.cs new file mode 100644 index 0000000..f52070d --- /dev/null +++ b/src/Purrse.Core/DTOs/ReportDtos.cs @@ -0,0 +1,32 @@ +namespace Purrse.Core.DTOs; + +public record SpendingByCategoryReport( + DateTime StartDate, + DateTime EndDate, + decimal TotalSpending, + List Categories +); + +public record CategorySpending(string CategoryName, decimal Amount, decimal Percentage, string? ParentCategoryName); + +public record IncomeVsExpenseReport( + DateTime StartDate, + DateTime EndDate, + List Months +); + +public record MonthlyIncomeExpense(int Year, int Month, decimal Income, decimal Expenses, decimal Net); + +public record NetWorthReport(List 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 Entries +); + +public record CashFlowEntry(DateTime Date, decimal Inflow, decimal Outflow, decimal Net); diff --git a/src/Purrse.Core/DTOs/ScheduledTransactionDtos.cs b/src/Purrse.Core/DTOs/ScheduledTransactionDtos.cs new file mode 100644 index 0000000..40384bd --- /dev/null +++ b/src/Purrse.Core/DTOs/ScheduledTransactionDtos.cs @@ -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 +); diff --git a/src/Purrse.Core/DTOs/TransactionDtos.cs b/src/Purrse.Core/DTOs/TransactionDtos.cs new file mode 100644 index 0000000..0dfa914 --- /dev/null +++ b/src/Purrse.Core/DTOs/TransactionDtos.cs @@ -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? 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? 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? 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(List Items, int TotalCount, int Page, int PageSize, int TotalPages); diff --git a/src/Purrse.Core/Enums/AccountType.cs b/src/Purrse.Core/Enums/AccountType.cs new file mode 100644 index 0000000..3479ebd --- /dev/null +++ b/src/Purrse.Core/Enums/AccountType.cs @@ -0,0 +1,16 @@ +namespace Purrse.Core.Enums; + +public enum AccountType +{ + Checking, + Savings, + CreditCard, + AutoLoan, + PersonalLoan, + Mortgage, + LineOfCredit, + Retirement401k, + IRA, + Brokerage, + Cash +} diff --git a/src/Purrse.Core/Enums/CategoryType.cs b/src/Purrse.Core/Enums/CategoryType.cs new file mode 100644 index 0000000..07f2a82 --- /dev/null +++ b/src/Purrse.Core/Enums/CategoryType.cs @@ -0,0 +1,8 @@ +namespace Purrse.Core.Enums; + +public enum CategoryType +{ + Income, + Expense, + Transfer +} diff --git a/src/Purrse.Core/Enums/Frequency.cs b/src/Purrse.Core/Enums/Frequency.cs new file mode 100644 index 0000000..a6536ca --- /dev/null +++ b/src/Purrse.Core/Enums/Frequency.cs @@ -0,0 +1,13 @@ +namespace Purrse.Core.Enums; + +public enum Frequency +{ + Daily, + Weekly, + BiWeekly, + SemiMonthly, + Monthly, + Quarterly, + SemiAnnually, + Annually +} diff --git a/src/Purrse.Core/Enums/ImportStatus.cs b/src/Purrse.Core/Enums/ImportStatus.cs new file mode 100644 index 0000000..ded3ac6 --- /dev/null +++ b/src/Purrse.Core/Enums/ImportStatus.cs @@ -0,0 +1,8 @@ +namespace Purrse.Core.Enums; + +public enum ImportStatus +{ + Pending, + Confirmed, + Rejected +} diff --git a/src/Purrse.Core/Enums/TransactionStatus.cs b/src/Purrse.Core/Enums/TransactionStatus.cs new file mode 100644 index 0000000..7c76007 --- /dev/null +++ b/src/Purrse.Core/Enums/TransactionStatus.cs @@ -0,0 +1,8 @@ +namespace Purrse.Core.Enums; + +public enum TransactionStatus +{ + Uncleared, + Cleared, + Reconciled +} diff --git a/src/Purrse.Core/Enums/TransactionType.cs b/src/Purrse.Core/Enums/TransactionType.cs new file mode 100644 index 0000000..197fc2f --- /dev/null +++ b/src/Purrse.Core/Enums/TransactionType.cs @@ -0,0 +1,8 @@ +namespace Purrse.Core.Enums; + +public enum TransactionType +{ + Debit, + Credit, + Transfer +} diff --git a/src/Purrse.Core/Helpers/StringSimilarity.cs b/src/Purrse.Core/Helpers/StringSimilarity.cs new file mode 100644 index 0000000..2fa996d --- /dev/null +++ b/src/Purrse.Core/Helpers/StringSimilarity.cs @@ -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()); + } +} diff --git a/src/Purrse.Core/Helpers/TransactionFingerprint.cs b/src/Purrse.Core/Helpers/TransactionFingerprint.cs new file mode 100644 index 0000000..27268b0 --- /dev/null +++ b/src/Purrse.Core/Helpers/TransactionFingerprint.cs @@ -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); + } +} diff --git a/src/Purrse.Core/Interfaces/Services/IAccountService.cs b/src/Purrse.Core/Interfaces/Services/IAccountService.cs new file mode 100644 index 0000000..585f289 --- /dev/null +++ b/src/Purrse.Core/Interfaces/Services/IAccountService.cs @@ -0,0 +1,14 @@ +using Purrse.Core.DTOs; + +namespace Purrse.Core.Interfaces.Services; + +public interface IAccountService +{ + Task> GetAllAsync(Guid userId); + Task GetByIdAsync(Guid userId, Guid accountId); + Task CreateAsync(Guid userId, CreateAccountRequest request); + Task UpdateAsync(Guid userId, Guid accountId, UpdateAccountRequest request); + Task DeleteAsync(Guid userId, Guid accountId); + Task> GetBalanceHistoryAsync(Guid userId, Guid accountId, DateTime startDate, DateTime endDate); + Task RecalculateBalanceAsync(Guid accountId); +} diff --git a/src/Purrse.Core/Interfaces/Services/IAuthService.cs b/src/Purrse.Core/Interfaces/Services/IAuthService.cs new file mode 100644 index 0000000..e8320ed --- /dev/null +++ b/src/Purrse.Core/Interfaces/Services/IAuthService.cs @@ -0,0 +1,11 @@ +using Purrse.Core.DTOs; + +namespace Purrse.Core.Interfaces.Services; + +public interface IAuthService +{ + Task RegisterAsync(RegisterRequest request); + Task LoginAsync(LoginRequest request); + Task RefreshTokenAsync(string refreshToken); + Task RevokeRefreshTokenAsync(Guid userId); +} diff --git a/src/Purrse.Core/Interfaces/Services/IBudgetService.cs b/src/Purrse.Core/Interfaces/Services/IBudgetService.cs new file mode 100644 index 0000000..bbb807d --- /dev/null +++ b/src/Purrse.Core/Interfaces/Services/IBudgetService.cs @@ -0,0 +1,12 @@ +using Purrse.Core.DTOs; + +namespace Purrse.Core.Interfaces.Services; + +public interface IBudgetService +{ + Task GetByMonthAsync(Guid userId, int year, int month); + Task CreateAsync(Guid userId, CreateBudgetRequest request); + Task UpdateAsync(Guid userId, Guid budgetId, UpdateBudgetRequest request); + Task DeleteAsync(Guid userId, Guid budgetId); + Task> GetActualsAsync(Guid userId, Guid budgetId); +} diff --git a/src/Purrse.Core/Interfaces/Services/ICategoryService.cs b/src/Purrse.Core/Interfaces/Services/ICategoryService.cs new file mode 100644 index 0000000..a5558ec --- /dev/null +++ b/src/Purrse.Core/Interfaces/Services/ICategoryService.cs @@ -0,0 +1,14 @@ +using Purrse.Core.DTOs; + +namespace Purrse.Core.Interfaces.Services; + +public interface ICategoryService +{ + Task> GetAllAsync(Guid userId); + Task> GetTreeAsync(Guid userId); + Task GetByIdAsync(Guid userId, Guid categoryId); + Task CreateAsync(Guid userId, CreateCategoryRequest request); + Task UpdateAsync(Guid userId, Guid categoryId, UpdateCategoryRequest request); + Task DeleteAsync(Guid userId, Guid categoryId); + Task SeedDefaultCategoriesAsync(Guid userId); +} diff --git a/src/Purrse.Core/Interfaces/Services/IDashboardService.cs b/src/Purrse.Core/Interfaces/Services/IDashboardService.cs new file mode 100644 index 0000000..cd015ae --- /dev/null +++ b/src/Purrse.Core/Interfaces/Services/IDashboardService.cs @@ -0,0 +1,8 @@ +using Purrse.Core.DTOs; + +namespace Purrse.Core.Interfaces.Services; + +public interface IDashboardService +{ + Task GetDashboardAsync(Guid userId); +} diff --git a/src/Purrse.Core/Interfaces/Services/IDuplicateDetectionService.cs b/src/Purrse.Core/Interfaces/Services/IDuplicateDetectionService.cs new file mode 100644 index 0000000..f020fe4 --- /dev/null +++ b/src/Purrse.Core/Interfaces/Services/IDuplicateDetectionService.cs @@ -0,0 +1,10 @@ +using Purrse.Core.DTOs; +using Purrse.Core.Models; + +namespace Purrse.Core.Interfaces.Services; + +public interface IDuplicateDetectionService +{ + Task FindDuplicateAsync(Guid accountId, DateTime date, decimal amount, string? payeeName, string? fitId, string? checkNumber); + double CalculateConfidence(Transaction existing, DateTime date, decimal amount, string? payeeName, string? fitId, string? checkNumber); +} diff --git a/src/Purrse.Core/Interfaces/Services/IImportService.cs b/src/Purrse.Core/Interfaces/Services/IImportService.cs new file mode 100644 index 0000000..ca052e9 --- /dev/null +++ b/src/Purrse.Core/Interfaces/Services/IImportService.cs @@ -0,0 +1,22 @@ +using Purrse.Core.DTOs; + +namespace Purrse.Core.Interfaces.Services; + +public interface IImportService +{ + Task UploadAsync(Guid userId, Guid accountId, string fileName, Stream fileStream); + Task ConfirmBatchAsync(Guid userId, Guid batchId); + Task ResolveDuplicatesAsync(Guid userId, Guid batchId, ResolveDuplicatesRequest request); +} + +public record ImportBatchResponse( + Guid Id, + string FileName, + string FileType, + int TotalCount, + int ImportedCount, + int DuplicateCount, + int SkippedCount, + string Status, + DateTime ImportedAt +); diff --git a/src/Purrse.Core/Interfaces/Services/ILoanService.cs b/src/Purrse.Core/Interfaces/Services/ILoanService.cs new file mode 100644 index 0000000..485b273 --- /dev/null +++ b/src/Purrse.Core/Interfaces/Services/ILoanService.cs @@ -0,0 +1,12 @@ +using Purrse.Core.DTOs; + +namespace Purrse.Core.Interfaces.Services; + +public interface ILoanService +{ + Task GetLoanDetailAsync(Guid userId, Guid accountId); + Task CreateLoanDetailAsync(Guid userId, Guid accountId, CreateLoanDetailRequest request); + Task UpdateLoanDetailAsync(Guid userId, Guid accountId, CreateLoanDetailRequest request); + Task> GetAmortizationScheduleAsync(Guid userId, Guid accountId); + Task CalculatePayoffScenarioAsync(Guid userId, Guid accountId, PayoffScenarioRequest request); +} diff --git a/src/Purrse.Core/Interfaces/Services/IPayeeService.cs b/src/Purrse.Core/Interfaces/Services/IPayeeService.cs new file mode 100644 index 0000000..3aa377d --- /dev/null +++ b/src/Purrse.Core/Interfaces/Services/IPayeeService.cs @@ -0,0 +1,16 @@ +using Purrse.Core.DTOs; +using Purrse.Core.Models; + +namespace Purrse.Core.Interfaces.Services; + +public interface IPayeeService +{ + Task> GetAllAsync(Guid userId); + Task GetByIdAsync(Guid userId, Guid payeeId); + Task CreateAsync(Guid userId, CreatePayeeRequest request); + Task UpdateAsync(Guid userId, Guid payeeId, UpdatePayeeRequest request); + Task DeleteAsync(Guid userId, Guid payeeId); + Task AddAliasAsync(Guid userId, Guid payeeId, string alias); + Task RemoveAliasAsync(Guid userId, Guid payeeId, Guid aliasId); + Task MatchPayeeAsync(Guid userId, string importedName); +} diff --git a/src/Purrse.Core/Interfaces/Services/IReportService.cs b/src/Purrse.Core/Interfaces/Services/IReportService.cs new file mode 100644 index 0000000..c8cf9ca --- /dev/null +++ b/src/Purrse.Core/Interfaces/Services/IReportService.cs @@ -0,0 +1,11 @@ +using Purrse.Core.DTOs; + +namespace Purrse.Core.Interfaces.Services; + +public interface IReportService +{ + Task GetSpendingByCategoryAsync(Guid userId, DateTime startDate, DateTime endDate, Guid? accountId = null); + Task GetIncomeVsExpenseAsync(Guid userId, DateTime startDate, DateTime endDate); + Task GetNetWorthAsync(Guid userId, DateTime startDate, DateTime endDate); + Task GetCashFlowAsync(Guid userId, DateTime startDate, DateTime endDate); +} diff --git a/src/Purrse.Core/Interfaces/Services/IScheduledTransactionService.cs b/src/Purrse.Core/Interfaces/Services/IScheduledTransactionService.cs new file mode 100644 index 0000000..e24665c --- /dev/null +++ b/src/Purrse.Core/Interfaces/Services/IScheduledTransactionService.cs @@ -0,0 +1,15 @@ +using Purrse.Core.DTOs; + +namespace Purrse.Core.Interfaces.Services; + +public interface IScheduledTransactionService +{ + Task> GetAllAsync(Guid userId); + Task GetByIdAsync(Guid userId, Guid id); + Task CreateAsync(Guid userId, CreateScheduledTransactionRequest request); + Task UpdateAsync(Guid userId, Guid id, UpdateScheduledTransactionRequest request); + Task DeleteAsync(Guid userId, Guid id); + Task SkipAsync(Guid userId, Guid id); + Task PostNowAsync(Guid userId, Guid id); + Task ProcessDueTransactionsAsync(); +} diff --git a/src/Purrse.Core/Interfaces/Services/ITransactionService.cs b/src/Purrse.Core/Interfaces/Services/ITransactionService.cs new file mode 100644 index 0000000..67f189d --- /dev/null +++ b/src/Purrse.Core/Interfaces/Services/ITransactionService.cs @@ -0,0 +1,15 @@ +using Purrse.Core.DTOs; + +namespace Purrse.Core.Interfaces.Services; + +public interface ITransactionService +{ + Task> GetByAccountAsync(Guid userId, Guid accountId, int page, int pageSize); + Task GetByIdAsync(Guid userId, Guid transactionId); + Task CreateAsync(Guid userId, CreateTransactionRequest request); + Task UpdateAsync(Guid userId, Guid transactionId, UpdateTransactionRequest request); + Task DeleteAsync(Guid userId, Guid transactionId); + Task> SearchAsync(Guid userId, TransactionSearchRequest request); + Task<(TransactionResponse From, TransactionResponse To)> CreateTransferAsync(Guid userId, CreateTransferRequest request); + Task VoidAsync(Guid userId, Guid transactionId); +} diff --git a/src/Purrse.Core/Models/Account.cs b/src/Purrse.Core/Models/Account.cs new file mode 100644 index 0000000..288005f --- /dev/null +++ b/src/Purrse.Core/Models/Account.cs @@ -0,0 +1,28 @@ +using Purrse.Core.Enums; + +namespace Purrse.Core.Models; + +public class Account +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public string Name { get; set; } = string.Empty; + public AccountType Type { get; set; } + public string? Institution { get; set; } + public string? AccountNumber { get; set; } + public decimal Balance { get; set; } + public decimal? CreditLimit { get; set; } + public decimal? InterestRate { get; set; } + public bool IsActive { get; set; } = true; + public bool IsClosed { get; set; } + public string? Notes { get; set; } + public int SortOrder { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + public User User { get; set; } = null!; + public LoanDetail? LoanDetail { get; set; } + public ICollection Transactions { get; set; } = new List(); + public ICollection InvestmentHoldings { get; set; } = new List(); + public ICollection Reconciliations { get; set; } = new List(); +} diff --git a/src/Purrse.Core/Models/AmortizationEntry.cs b/src/Purrse.Core/Models/AmortizationEntry.cs new file mode 100644 index 0000000..797e707 --- /dev/null +++ b/src/Purrse.Core/Models/AmortizationEntry.cs @@ -0,0 +1,16 @@ +namespace Purrse.Core.Models; + +public class AmortizationEntry +{ + public Guid Id { get; set; } + public Guid LoanDetailId { get; set; } + public int PaymentNumber { get; set; } + public DateTime PaymentDate { get; set; } + public decimal PaymentAmount { get; set; } + public decimal PrincipalAmount { get; set; } + public decimal InterestAmount { get; set; } + public decimal? EscrowAmount { get; set; } + public decimal RemainingBalance { get; set; } + + public LoanDetail LoanDetail { get; set; } = null!; +} diff --git a/src/Purrse.Core/Models/Budget.cs b/src/Purrse.Core/Models/Budget.cs new file mode 100644 index 0000000..4b2cd15 --- /dev/null +++ b/src/Purrse.Core/Models/Budget.cs @@ -0,0 +1,14 @@ +namespace Purrse.Core.Models; + +public class Budget +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public int Year { get; set; } + public int Month { get; set; } + public string? Notes { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public User User { get; set; } = null!; + public ICollection Items { get; set; } = new List(); +} diff --git a/src/Purrse.Core/Models/BudgetItem.cs b/src/Purrse.Core/Models/BudgetItem.cs new file mode 100644 index 0000000..8394678 --- /dev/null +++ b/src/Purrse.Core/Models/BudgetItem.cs @@ -0,0 +1,12 @@ +namespace Purrse.Core.Models; + +public class BudgetItem +{ + public Guid Id { get; set; } + public Guid BudgetId { get; set; } + public Guid CategoryId { get; set; } + public decimal BudgetedAmount { get; set; } + + public Budget Budget { get; set; } = null!; + public Category Category { get; set; } = null!; +} diff --git a/src/Purrse.Core/Models/Category.cs b/src/Purrse.Core/Models/Category.cs new file mode 100644 index 0000000..91acbaa --- /dev/null +++ b/src/Purrse.Core/Models/Category.cs @@ -0,0 +1,21 @@ +using Purrse.Core.Enums; + +namespace Purrse.Core.Models; + +public class Category +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public string Name { get; set; } = string.Empty; + public CategoryType Type { get; set; } + public Guid? ParentId { get; set; } + public int SortOrder { get; set; } + public bool IsSystem { get; set; } + public bool IsActive { get; set; } = true; + + public User User { get; set; } = null!; + public Category? Parent { get; set; } + public ICollection Children { get; set; } = new List(); + public ICollection Transactions { get; set; } = new List(); + public ICollection BudgetItems { get; set; } = new List(); +} diff --git a/src/Purrse.Core/Models/ImportBatch.cs b/src/Purrse.Core/Models/ImportBatch.cs new file mode 100644 index 0000000..edda8cc --- /dev/null +++ b/src/Purrse.Core/Models/ImportBatch.cs @@ -0,0 +1,22 @@ +using Purrse.Core.Enums; + +namespace Purrse.Core.Models; + +public class ImportBatch +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public Guid AccountId { get; set; } + public string FileName { get; set; } = string.Empty; + public string FileType { get; set; } = string.Empty; + public int TotalCount { get; set; } + public int ImportedCount { get; set; } + public int DuplicateCount { get; set; } + public int SkippedCount { get; set; } + public ImportStatus Status { get; set; } = ImportStatus.Pending; + public DateTime ImportedAt { get; set; } = DateTime.UtcNow; + + public User User { get; set; } = null!; + public Account Account { get; set; } = null!; + public ICollection Transactions { get; set; } = new List(); +} diff --git a/src/Purrse.Core/Models/InvestmentHolding.cs b/src/Purrse.Core/Models/InvestmentHolding.cs new file mode 100644 index 0000000..bd8232f --- /dev/null +++ b/src/Purrse.Core/Models/InvestmentHolding.cs @@ -0,0 +1,14 @@ +namespace Purrse.Core.Models; + +public class InvestmentHolding +{ + public Guid Id { get; set; } + public Guid AccountId { get; set; } + public Guid SecurityId { get; set; } + public decimal Shares { get; set; } + public decimal CostBasis { get; set; } + public DateTime AsOfDate { get; set; } + + public Account Account { get; set; } = null!; + public Security Security { get; set; } = null!; +} diff --git a/src/Purrse.Core/Models/LoanDetail.cs b/src/Purrse.Core/Models/LoanDetail.cs new file mode 100644 index 0000000..78185b8 --- /dev/null +++ b/src/Purrse.Core/Models/LoanDetail.cs @@ -0,0 +1,18 @@ +namespace Purrse.Core.Models; + +public class LoanDetail +{ + public Guid Id { get; set; } + public Guid AccountId { get; set; } + public decimal OriginalBalance { get; set; } + public decimal InterestRate { get; set; } + public int TermMonths { get; set; } + public decimal MonthlyPayment { get; set; } + public DateTime OriginationDate { get; set; } + public DateTime MaturityDate { get; set; } + public decimal? EscrowAmount { get; set; } + public decimal? ExtraPayment { get; set; } + + public Account Account { get; set; } = null!; + public ICollection AmortizationEntries { get; set; } = new List(); +} diff --git a/src/Purrse.Core/Models/Payee.cs b/src/Purrse.Core/Models/Payee.cs new file mode 100644 index 0000000..52ed4ef --- /dev/null +++ b/src/Purrse.Core/Models/Payee.cs @@ -0,0 +1,15 @@ +namespace Purrse.Core.Models; + +public class Payee +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public string Name { get; set; } = string.Empty; + public Guid? DefaultCategoryId { get; set; } + public bool IsActive { get; set; } = true; + + public User User { get; set; } = null!; + public Category? DefaultCategory { get; set; } + public ICollection Aliases { get; set; } = new List(); + public ICollection Transactions { get; set; } = new List(); +} diff --git a/src/Purrse.Core/Models/PayeeAlias.cs b/src/Purrse.Core/Models/PayeeAlias.cs new file mode 100644 index 0000000..ffb1115 --- /dev/null +++ b/src/Purrse.Core/Models/PayeeAlias.cs @@ -0,0 +1,10 @@ +namespace Purrse.Core.Models; + +public class PayeeAlias +{ + public Guid Id { get; set; } + public Guid PayeeId { get; set; } + public string Alias { get; set; } = string.Empty; + + public Payee Payee { get; set; } = null!; +} diff --git a/src/Purrse.Core/Models/PluginConfiguration.cs b/src/Purrse.Core/Models/PluginConfiguration.cs new file mode 100644 index 0000000..3b014ad --- /dev/null +++ b/src/Purrse.Core/Models/PluginConfiguration.cs @@ -0,0 +1,11 @@ +namespace Purrse.Core.Models; + +public class PluginConfiguration +{ + public Guid Id { get; set; } + public Guid PluginRegistrationId { get; set; } + public string Key { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + + public PluginRegistration PluginRegistration { get; set; } = null!; +} diff --git a/src/Purrse.Core/Models/PluginRegistration.cs b/src/Purrse.Core/Models/PluginRegistration.cs new file mode 100644 index 0000000..817abb3 --- /dev/null +++ b/src/Purrse.Core/Models/PluginRegistration.cs @@ -0,0 +1,15 @@ +namespace Purrse.Core.Models; + +public class PluginRegistration +{ + public Guid Id { get; set; } + public string PluginId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Version { get; set; } = string.Empty; + public string EntryAssembly { get; set; } = string.Empty; + public string EntryType { get; set; } = string.Empty; + public bool IsEnabled { get; set; } = true; + public DateTime InstalledAt { get; set; } = DateTime.UtcNow; + + public ICollection Configurations { get; set; } = new List(); +} diff --git a/src/Purrse.Core/Models/Reconciliation.cs b/src/Purrse.Core/Models/Reconciliation.cs new file mode 100644 index 0000000..741f787 --- /dev/null +++ b/src/Purrse.Core/Models/Reconciliation.cs @@ -0,0 +1,15 @@ +namespace Purrse.Core.Models; + +public class Reconciliation +{ + public Guid Id { get; set; } + public Guid AccountId { get; set; } + public DateTime StatementDate { get; set; } + public decimal StatementBalance { get; set; } + public bool IsCompleted { get; set; } + public DateTime? CompletedAt { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public Account Account { get; set; } = null!; + public ICollection Transactions { get; set; } = new List(); +} diff --git a/src/Purrse.Core/Models/ScheduledTransaction.cs b/src/Purrse.Core/Models/ScheduledTransaction.cs new file mode 100644 index 0000000..3b3686b --- /dev/null +++ b/src/Purrse.Core/Models/ScheduledTransaction.cs @@ -0,0 +1,28 @@ +using Purrse.Core.Enums; + +namespace Purrse.Core.Models; + +public class ScheduledTransaction +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public Guid AccountId { get; set; } + public decimal Amount { get; set; } + public string? PayeeName { get; set; } + public Guid? PayeeId { get; set; } + public Guid? CategoryId { get; set; } + public string? Memo { get; set; } + public TransactionType Type { get; set; } + public Frequency Frequency { get; set; } + public DateTime NextDueDate { get; set; } + public DateTime? EndDate { get; set; } + public bool AutoPost { get; set; } + public int ReminderDaysBefore { get; set; } = 3; + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public User User { get; set; } = null!; + public Account Account { get; set; } = null!; + public Payee? Payee { get; set; } + public Category? Category { get; set; } +} diff --git a/src/Purrse.Core/Models/Security.cs b/src/Purrse.Core/Models/Security.cs new file mode 100644 index 0000000..4d65129 --- /dev/null +++ b/src/Purrse.Core/Models/Security.cs @@ -0,0 +1,12 @@ +namespace Purrse.Core.Models; + +public class Security +{ + public Guid Id { get; set; } + public string Symbol { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string? SecurityType { get; set; } + + public ICollection Holdings { get; set; } = new List(); + public ICollection Prices { get; set; } = new List(); +} diff --git a/src/Purrse.Core/Models/SecurityPrice.cs b/src/Purrse.Core/Models/SecurityPrice.cs new file mode 100644 index 0000000..a4bc56e --- /dev/null +++ b/src/Purrse.Core/Models/SecurityPrice.cs @@ -0,0 +1,11 @@ +namespace Purrse.Core.Models; + +public class SecurityPrice +{ + public Guid Id { get; set; } + public Guid SecurityId { get; set; } + public DateTime Date { get; set; } + public decimal Price { get; set; } + + public Security Security { get; set; } = null!; +} diff --git a/src/Purrse.Core/Models/Transaction.cs b/src/Purrse.Core/Models/Transaction.cs new file mode 100644 index 0000000..6255040 --- /dev/null +++ b/src/Purrse.Core/Models/Transaction.cs @@ -0,0 +1,35 @@ +using Purrse.Core.Enums; + +namespace Purrse.Core.Models; + +public class Transaction +{ + public Guid Id { get; set; } + public Guid AccountId { get; set; } + public DateTime Date { get; set; } + public decimal Amount { get; set; } + public Guid? PayeeId { get; set; } + public string? PayeeName { get; set; } + public Guid? CategoryId { get; set; } + public string? Memo { get; set; } + public string? ReferenceNumber { get; set; } + public string? CheckNumber { get; set; } + public TransactionType Type { get; set; } + public TransactionStatus Status { get; set; } = TransactionStatus.Uncleared; + public string? FitId { get; set; } + public string? Fingerprint { get; set; } + public Guid? TransferTransactionId { get; set; } + public Guid? ImportBatchId { get; set; } + public Guid? ReconciliationId { get; set; } + public bool IsVoid { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + public Account Account { get; set; } = null!; + public Payee? Payee { get; set; } + public Category? Category { get; set; } + public Transaction? TransferTransaction { get; set; } + public ImportBatch? ImportBatch { get; set; } + public Reconciliation? Reconciliation { get; set; } + public ICollection Splits { get; set; } = new List(); +} diff --git a/src/Purrse.Core/Models/TransactionSplit.cs b/src/Purrse.Core/Models/TransactionSplit.cs new file mode 100644 index 0000000..f2a86f8 --- /dev/null +++ b/src/Purrse.Core/Models/TransactionSplit.cs @@ -0,0 +1,13 @@ +namespace Purrse.Core.Models; + +public class TransactionSplit +{ + public Guid Id { get; set; } + public Guid TransactionId { get; set; } + public Guid? CategoryId { get; set; } + public decimal Amount { get; set; } + public string? Memo { get; set; } + + public Transaction Transaction { get; set; } = null!; + public Category? Category { get; set; } +} diff --git a/src/Purrse.Core/Models/User.cs b/src/Purrse.Core/Models/User.cs new file mode 100644 index 0000000..769d87c --- /dev/null +++ b/src/Purrse.Core/Models/User.cs @@ -0,0 +1,15 @@ +namespace Purrse.Core.Models; + +public class User +{ + public Guid Id { get; set; } + public string Username { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string PasswordHash { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? LastLoginAt { get; set; } + public string? RefreshToken { get; set; } + public DateTime? RefreshTokenExpiresAt { get; set; } + + public ICollection Accounts { get; set; } = new List(); +} diff --git a/src/Purrse.Core/Purrse.Core.csproj b/src/Purrse.Core/Purrse.Core.csproj new file mode 100644 index 0000000..10ebc12 --- /dev/null +++ b/src/Purrse.Core/Purrse.Core.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + diff --git a/src/Purrse.Data/Configurations/AccountConfiguration.cs b/src/Purrse.Data/Configurations/AccountConfiguration.cs new file mode 100644 index 0000000..66e7f8c --- /dev/null +++ b/src/Purrse.Data/Configurations/AccountConfiguration.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Purrse.Core.Models; + +namespace Purrse.Data.Configurations; + +public class AccountConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("accounts"); + builder.HasKey(a => a.Id); + builder.Property(a => a.Name).HasMaxLength(200).IsRequired(); + builder.Property(a => a.Institution).HasMaxLength(200); + builder.Property(a => a.AccountNumber).HasMaxLength(50); + builder.Property(a => a.Balance).HasPrecision(18, 2); + builder.Property(a => a.CreditLimit).HasPrecision(18, 2); + builder.Property(a => a.InterestRate).HasPrecision(8, 4); + builder.Property(a => a.Notes).HasMaxLength(1000); + + builder.HasOne(a => a.User) + .WithMany(u => u.Accounts) + .HasForeignKey(a => a.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(a => a.LoanDetail) + .WithOne(l => l.Account) + .HasForeignKey(l => l.AccountId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(a => a.UserId); + } +} diff --git a/src/Purrse.Data/Configurations/AmortizationEntryConfiguration.cs b/src/Purrse.Data/Configurations/AmortizationEntryConfiguration.cs new file mode 100644 index 0000000..4ab737a --- /dev/null +++ b/src/Purrse.Data/Configurations/AmortizationEntryConfiguration.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Purrse.Core.Models; + +namespace Purrse.Data.Configurations; + +public class AmortizationEntryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("amortization_entries"); + builder.HasKey(a => a.Id); + builder.Property(a => a.PaymentAmount).HasPrecision(18, 2); + builder.Property(a => a.PrincipalAmount).HasPrecision(18, 2); + builder.Property(a => a.InterestAmount).HasPrecision(18, 2); + builder.Property(a => a.EscrowAmount).HasPrecision(18, 2); + builder.Property(a => a.RemainingBalance).HasPrecision(18, 2); + + builder.HasOne(a => a.LoanDetail) + .WithMany(l => l.AmortizationEntries) + .HasForeignKey(a => a.LoanDetailId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/Purrse.Data/Configurations/BudgetConfiguration.cs b/src/Purrse.Data/Configurations/BudgetConfiguration.cs new file mode 100644 index 0000000..d92c016 --- /dev/null +++ b/src/Purrse.Data/Configurations/BudgetConfiguration.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Purrse.Core.Models; + +namespace Purrse.Data.Configurations; + +public class BudgetConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("budgets"); + builder.HasKey(b => b.Id); + builder.Property(b => b.Notes).HasMaxLength(500); + + builder.HasOne(b => b.User) + .WithMany() + .HasForeignKey(b => b.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(b => new { b.UserId, b.Year, b.Month }).IsUnique(); + } +} diff --git a/src/Purrse.Data/Configurations/BudgetItemConfiguration.cs b/src/Purrse.Data/Configurations/BudgetItemConfiguration.cs new file mode 100644 index 0000000..b9c52b8 --- /dev/null +++ b/src/Purrse.Data/Configurations/BudgetItemConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Purrse.Core.Models; + +namespace Purrse.Data.Configurations; + +public class BudgetItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("budget_items"); + builder.HasKey(bi => bi.Id); + builder.Property(bi => bi.BudgetedAmount).HasPrecision(18, 2); + + builder.HasOne(bi => bi.Budget) + .WithMany(b => b.Items) + .HasForeignKey(bi => bi.BudgetId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(bi => bi.Category) + .WithMany(c => c.BudgetItems) + .HasForeignKey(bi => bi.CategoryId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/Purrse.Data/Configurations/CategoryConfiguration.cs b/src/Purrse.Data/Configurations/CategoryConfiguration.cs new file mode 100644 index 0000000..4ceb191 --- /dev/null +++ b/src/Purrse.Data/Configurations/CategoryConfiguration.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Purrse.Core.Models; + +namespace Purrse.Data.Configurations; + +public class CategoryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("categories"); + builder.HasKey(c => c.Id); + builder.Property(c => c.Name).HasMaxLength(200).IsRequired(); + + builder.HasOne(c => c.User) + .WithMany() + .HasForeignKey(c => c.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(c => c.Parent) + .WithMany(c => c.Children) + .HasForeignKey(c => c.ParentId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(c => new { c.UserId, c.Name, c.ParentId }).IsUnique(); + } +} diff --git a/src/Purrse.Data/Configurations/ImportBatchConfiguration.cs b/src/Purrse.Data/Configurations/ImportBatchConfiguration.cs new file mode 100644 index 0000000..2099525 --- /dev/null +++ b/src/Purrse.Data/Configurations/ImportBatchConfiguration.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Purrse.Core.Models; + +namespace Purrse.Data.Configurations; + +public class ImportBatchConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("import_batches"); + builder.HasKey(b => b.Id); + builder.Property(b => b.FileName).HasMaxLength(500).IsRequired(); + builder.Property(b => b.FileType).HasMaxLength(50).IsRequired(); + + builder.HasOne(b => b.User) + .WithMany() + .HasForeignKey(b => b.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(b => b.Account) + .WithMany() + .HasForeignKey(b => b.AccountId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/Purrse.Data/Configurations/InvestmentConfiguration.cs b/src/Purrse.Data/Configurations/InvestmentConfiguration.cs new file mode 100644 index 0000000..2c7bd8b --- /dev/null +++ b/src/Purrse.Data/Configurations/InvestmentConfiguration.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Purrse.Core.Models; + +namespace Purrse.Data.Configurations; + +public class InvestmentHoldingConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("investment_holdings"); + builder.HasKey(h => h.Id); + builder.Property(h => h.Shares).HasPrecision(18, 6); + builder.Property(h => h.CostBasis).HasPrecision(18, 2); + + builder.HasOne(h => h.Account) + .WithMany(a => a.InvestmentHoldings) + .HasForeignKey(h => h.AccountId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(h => h.Security) + .WithMany(s => s.Holdings) + .HasForeignKey(h => h.SecurityId) + .OnDelete(DeleteBehavior.Cascade); + } +} + +public class SecurityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("securities"); + builder.HasKey(s => s.Id); + builder.Property(s => s.Symbol).HasMaxLength(20).IsRequired(); + builder.Property(s => s.Name).HasMaxLength(200).IsRequired(); + builder.Property(s => s.SecurityType).HasMaxLength(50); + + builder.HasIndex(s => s.Symbol).IsUnique(); + } +} + +public class SecurityPriceConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("security_prices"); + builder.HasKey(p => p.Id); + builder.Property(p => p.Price).HasPrecision(18, 4); + + builder.HasOne(p => p.Security) + .WithMany(s => s.Prices) + .HasForeignKey(p => p.SecurityId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(p => new { p.SecurityId, p.Date }).IsUnique(); + } +} diff --git a/src/Purrse.Data/Configurations/LoanDetailConfiguration.cs b/src/Purrse.Data/Configurations/LoanDetailConfiguration.cs new file mode 100644 index 0000000..be60683 --- /dev/null +++ b/src/Purrse.Data/Configurations/LoanDetailConfiguration.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Purrse.Core.Models; + +namespace Purrse.Data.Configurations; + +public class LoanDetailConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("loan_details"); + builder.HasKey(l => l.Id); + builder.Property(l => l.OriginalBalance).HasPrecision(18, 2); + builder.Property(l => l.InterestRate).HasPrecision(8, 4); + builder.Property(l => l.MonthlyPayment).HasPrecision(18, 2); + builder.Property(l => l.EscrowAmount).HasPrecision(18, 2); + builder.Property(l => l.ExtraPayment).HasPrecision(18, 2); + } +} diff --git a/src/Purrse.Data/Configurations/PayeeAliasConfiguration.cs b/src/Purrse.Data/Configurations/PayeeAliasConfiguration.cs new file mode 100644 index 0000000..dac85c3 --- /dev/null +++ b/src/Purrse.Data/Configurations/PayeeAliasConfiguration.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Purrse.Core.Models; + +namespace Purrse.Data.Configurations; + +public class PayeeAliasConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("payee_aliases"); + builder.HasKey(a => a.Id); + builder.Property(a => a.Alias).HasMaxLength(300).IsRequired(); + + builder.HasOne(a => a.Payee) + .WithMany(p => p.Aliases) + .HasForeignKey(a => a.PayeeId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(a => a.Alias); + } +} diff --git a/src/Purrse.Data/Configurations/PayeeConfiguration.cs b/src/Purrse.Data/Configurations/PayeeConfiguration.cs new file mode 100644 index 0000000..7fc8fc6 --- /dev/null +++ b/src/Purrse.Data/Configurations/PayeeConfiguration.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Purrse.Core.Models; + +namespace Purrse.Data.Configurations; + +public class PayeeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("payees"); + builder.HasKey(p => p.Id); + builder.Property(p => p.Name).HasMaxLength(300).IsRequired(); + + builder.HasOne(p => p.User) + .WithMany() + .HasForeignKey(p => p.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(p => p.DefaultCategory) + .WithMany() + .HasForeignKey(p => p.DefaultCategoryId) + .OnDelete(DeleteBehavior.SetNull); + + builder.HasIndex(p => new { p.UserId, p.Name }).IsUnique(); + } +} diff --git a/src/Purrse.Data/Configurations/PluginConfiguration.cs b/src/Purrse.Data/Configurations/PluginConfiguration.cs new file mode 100644 index 0000000..d5032e8 --- /dev/null +++ b/src/Purrse.Data/Configurations/PluginConfiguration.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Purrse.Core.Models; + +namespace Purrse.Data.Configurations; + +public class PluginRegistrationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("plugin_registrations"); + builder.HasKey(p => p.Id); + builder.Property(p => p.PluginId).HasMaxLength(200).IsRequired(); + builder.Property(p => p.Name).HasMaxLength(200).IsRequired(); + builder.Property(p => p.Version).HasMaxLength(50).IsRequired(); + builder.Property(p => p.EntryAssembly).HasMaxLength(500); + builder.Property(p => p.EntryType).HasMaxLength(500); + + builder.HasIndex(p => p.PluginId).IsUnique(); + } +} + +public class PluginConfigurationEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("plugin_configurations"); + builder.HasKey(c => c.Id); + builder.Property(c => c.Key).HasMaxLength(200).IsRequired(); + builder.Property(c => c.Value).HasMaxLength(2000); + + builder.HasOne(c => c.PluginRegistration) + .WithMany(p => p.Configurations) + .HasForeignKey(c => c.PluginRegistrationId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(c => new { c.PluginRegistrationId, c.Key }).IsUnique(); + } +} diff --git a/src/Purrse.Data/Configurations/ReconciliationConfiguration.cs b/src/Purrse.Data/Configurations/ReconciliationConfiguration.cs new file mode 100644 index 0000000..893c7de --- /dev/null +++ b/src/Purrse.Data/Configurations/ReconciliationConfiguration.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Purrse.Core.Models; + +namespace Purrse.Data.Configurations; + +public class ReconciliationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("reconciliations"); + builder.HasKey(r => r.Id); + builder.Property(r => r.StatementBalance).HasPrecision(18, 2); + + builder.HasOne(r => r.Account) + .WithMany(a => a.Reconciliations) + .HasForeignKey(r => r.AccountId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/Purrse.Data/Configurations/ScheduledTransactionConfiguration.cs b/src/Purrse.Data/Configurations/ScheduledTransactionConfiguration.cs new file mode 100644 index 0000000..c021604 --- /dev/null +++ b/src/Purrse.Data/Configurations/ScheduledTransactionConfiguration.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Purrse.Core.Models; + +namespace Purrse.Data.Configurations; + +public class ScheduledTransactionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("scheduled_transactions"); + builder.HasKey(s => s.Id); + builder.Property(s => s.Amount).HasPrecision(18, 2); + builder.Property(s => s.PayeeName).HasMaxLength(500); + builder.Property(s => s.Memo).HasMaxLength(1000); + + builder.HasOne(s => s.User) + .WithMany() + .HasForeignKey(s => s.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(s => s.Account) + .WithMany() + .HasForeignKey(s => s.AccountId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(s => s.Payee) + .WithMany() + .HasForeignKey(s => s.PayeeId) + .OnDelete(DeleteBehavior.SetNull); + + builder.HasOne(s => s.Category) + .WithMany() + .HasForeignKey(s => s.CategoryId) + .OnDelete(DeleteBehavior.SetNull); + } +} diff --git a/src/Purrse.Data/Configurations/TransactionConfiguration.cs b/src/Purrse.Data/Configurations/TransactionConfiguration.cs new file mode 100644 index 0000000..7702906 --- /dev/null +++ b/src/Purrse.Data/Configurations/TransactionConfiguration.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Purrse.Core.Models; + +namespace Purrse.Data.Configurations; + +public class TransactionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("transactions"); + builder.HasKey(t => t.Id); + builder.Property(t => t.Amount).HasPrecision(18, 2); + builder.Property(t => t.PayeeName).HasMaxLength(500); + builder.Property(t => t.Memo).HasMaxLength(1000); + builder.Property(t => t.ReferenceNumber).HasMaxLength(100); + builder.Property(t => t.CheckNumber).HasMaxLength(20); + builder.Property(t => t.FitId).HasMaxLength(255); + builder.Property(t => t.Fingerprint).HasMaxLength(64); + + builder.HasOne(t => t.Account) + .WithMany(a => a.Transactions) + .HasForeignKey(t => t.AccountId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(t => t.Payee) + .WithMany(p => p.Transactions) + .HasForeignKey(t => t.PayeeId) + .OnDelete(DeleteBehavior.SetNull); + + builder.HasOne(t => t.Category) + .WithMany(c => c.Transactions) + .HasForeignKey(t => t.CategoryId) + .OnDelete(DeleteBehavior.SetNull); + + builder.HasOne(t => t.TransferTransaction) + .WithOne() + .HasForeignKey(t => t.TransferTransactionId) + .OnDelete(DeleteBehavior.SetNull); + + builder.HasOne(t => t.ImportBatch) + .WithMany(b => b.Transactions) + .HasForeignKey(t => t.ImportBatchId) + .OnDelete(DeleteBehavior.SetNull); + + builder.HasOne(t => t.Reconciliation) + .WithMany(r => r.Transactions) + .HasForeignKey(t => t.ReconciliationId) + .OnDelete(DeleteBehavior.SetNull); + + builder.HasIndex(t => t.AccountId); + builder.HasIndex(t => t.Date); + builder.HasIndex(t => t.Fingerprint); + builder.HasIndex(t => t.FitId); + } +} diff --git a/src/Purrse.Data/Configurations/TransactionSplitConfiguration.cs b/src/Purrse.Data/Configurations/TransactionSplitConfiguration.cs new file mode 100644 index 0000000..d3232f3 --- /dev/null +++ b/src/Purrse.Data/Configurations/TransactionSplitConfiguration.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Purrse.Core.Models; + +namespace Purrse.Data.Configurations; + +public class TransactionSplitConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("transaction_splits"); + builder.HasKey(s => s.Id); + builder.Property(s => s.Amount).HasPrecision(18, 2); + builder.Property(s => s.Memo).HasMaxLength(500); + + builder.HasOne(s => s.Transaction) + .WithMany(t => t.Splits) + .HasForeignKey(s => s.TransactionId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(s => s.Category) + .WithMany() + .HasForeignKey(s => s.CategoryId) + .OnDelete(DeleteBehavior.SetNull); + } +} diff --git a/src/Purrse.Data/Configurations/UserConfiguration.cs b/src/Purrse.Data/Configurations/UserConfiguration.cs new file mode 100644 index 0000000..9135989 --- /dev/null +++ b/src/Purrse.Data/Configurations/UserConfiguration.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Purrse.Core.Models; + +namespace Purrse.Data.Configurations; + +public class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("users"); + builder.HasKey(u => u.Id); + builder.Property(u => u.Username).HasMaxLength(100).IsRequired(); + builder.Property(u => u.Email).HasMaxLength(255).IsRequired(); + builder.Property(u => u.PasswordHash).HasMaxLength(255).IsRequired(); + builder.HasIndex(u => u.Username).IsUnique(); + builder.HasIndex(u => u.Email).IsUnique(); + } +} diff --git a/src/Purrse.Data/Purrse.Data.csproj b/src/Purrse.Data/Purrse.Data.csproj new file mode 100644 index 0000000..110aa0f --- /dev/null +++ b/src/Purrse.Data/Purrse.Data.csproj @@ -0,0 +1,21 @@ + + + + net9.0 + enable + enable + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/src/Purrse.Data/PurrseDbContext.cs b/src/Purrse.Data/PurrseDbContext.cs new file mode 100644 index 0000000..2bd2402 --- /dev/null +++ b/src/Purrse.Data/PurrseDbContext.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using Purrse.Core.Models; + +namespace Purrse.Data; + +public class PurrseDbContext : DbContext +{ + public PurrseDbContext(DbContextOptions options) : base(options) { } + + public DbSet Users => Set(); + public DbSet Accounts => Set(); + public DbSet Transactions => Set(); + public DbSet TransactionSplits => Set(); + public DbSet Categories => Set(); + public DbSet Payees => Set(); + public DbSet PayeeAliases => Set(); + public DbSet LoanDetails => Set(); + public DbSet AmortizationEntries => Set(); + public DbSet Budgets => Set(); + public DbSet BudgetItems => Set(); + public DbSet ScheduledTransactions => Set(); + public DbSet ImportBatches => Set(); + public DbSet InvestmentHoldings => Set(); + public DbSet Securities => Set(); + public DbSet SecurityPrices => Set(); + public DbSet Reconciliations => Set(); + public DbSet PluginRegistrations => Set(); + public DbSet PluginConfigurations => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.ApplyConfigurationsFromAssembly(typeof(PurrseDbContext).Assembly); + } +} diff --git a/src/Purrse.Data/Seeds/CategorySeeder.cs b/src/Purrse.Data/Seeds/CategorySeeder.cs new file mode 100644 index 0000000..eba2a74 --- /dev/null +++ b/src/Purrse.Data/Seeds/CategorySeeder.cs @@ -0,0 +1,100 @@ +using Purrse.Core.Enums; +using Purrse.Core.Models; + +namespace Purrse.Data.Seeds; + +public static class CategorySeeder +{ + public static List GetDefaultCategories(Guid userId) + { + var categories = new List(); + int sort = 0; + + // Income categories + AddCategory(categories, userId, "Income", CategoryType.Income, null, ref sort, true, + new[] { "Salary", "Bonus", "Interest Income", "Dividend Income", "Rental Income", "Freelance Income", "Gift Received", "Refund", "Other Income" }); + + // Expense categories + AddCategory(categories, userId, "Housing", CategoryType.Expense, null, ref sort, true, + new[] { "Mortgage Payment", "Rent", "Property Tax", "Home Insurance", "HOA Fees", "Home Maintenance", "Home Improvement" }); + + AddCategory(categories, userId, "Utilities", CategoryType.Expense, null, ref sort, true, + new[] { "Electric", "Gas", "Water & Sewer", "Trash", "Internet", "Phone", "Cable/Streaming" }); + + AddCategory(categories, userId, "Food", CategoryType.Expense, null, ref sort, true, + new[] { "Groceries", "Restaurants", "Fast Food", "Coffee Shops" }); + + AddCategory(categories, userId, "Transportation", CategoryType.Expense, null, ref sort, true, + new[] { "Auto Payment", "Auto Insurance", "Fuel", "Parking", "Public Transit", "Auto Maintenance", "Registration & Fees", "Tolls" }); + + AddCategory(categories, userId, "Healthcare", CategoryType.Expense, null, ref sort, true, + new[] { "Health Insurance", "Doctor", "Dentist", "Pharmacy", "Vision", "Hospital" }); + + AddCategory(categories, userId, "Personal", CategoryType.Expense, null, ref sort, true, + new[] { "Clothing", "Personal Care", "Hair & Beauty", "Gym & Fitness", "Education", "Books", "Subscriptions" }); + + AddCategory(categories, userId, "Entertainment", CategoryType.Expense, null, ref sort, true, + new[] { "Movies & Shows", "Music", "Games", "Hobbies", "Sports", "Vacations" }); + + AddCategory(categories, userId, "Financial", CategoryType.Expense, null, ref sort, true, + new[] { "Bank Fees", "Credit Card Interest", "Loan Interest", "Late Fees", "ATM Fees" }); + + AddCategory(categories, userId, "Insurance", CategoryType.Expense, null, ref sort, true, + new[] { "Life Insurance", "Disability Insurance", "Umbrella Insurance" }); + + AddCategory(categories, userId, "Taxes", CategoryType.Expense, null, ref sort, true, + new[] { "Federal Tax", "State Tax", "Local Tax", "Social Security", "Medicare" }); + + AddCategory(categories, userId, "Gifts & Donations", CategoryType.Expense, null, ref sort, true, + new[] { "Charity", "Gifts Given", "Religious" }); + + AddCategory(categories, userId, "Pets", CategoryType.Expense, null, ref sort, true, + new[] { "Pet Food", "Veterinary", "Pet Supplies" }); + + AddCategory(categories, userId, "Children", CategoryType.Expense, null, ref sort, true, + new[] { "Child Care", "School Expenses", "Allowance", "Activities" }); + + AddCategory(categories, userId, "Miscellaneous", CategoryType.Expense, null, ref sort, true, + new[] { "Other Expense", "Cash & ATM" }); + + // Transfer categories + AddCategory(categories, userId, "Transfer", CategoryType.Transfer, null, ref sort, true, + new[] { "Account Transfer", "Credit Card Payment", "Loan Payment", "Investment Contribution" }); + + return categories; + } + + private static void AddCategory(List categories, Guid userId, string parentName, + CategoryType type, Guid? grandParentId, ref int sort, bool isSystem, string[]? children) + { + var parentId = Guid.NewGuid(); + categories.Add(new Category + { + Id = parentId, + UserId = userId, + Name = parentName, + Type = type, + ParentId = grandParentId, + SortOrder = sort++, + IsSystem = isSystem, + IsActive = true + }); + + if (children == null) return; + + foreach (var child in children) + { + categories.Add(new Category + { + Id = Guid.NewGuid(), + UserId = userId, + Name = child, + Type = type, + ParentId = parentId, + SortOrder = sort++, + IsSystem = isSystem, + IsActive = true + }); + } + } +} diff --git a/src/Purrse.Plugins.Abstractions/IAccountSyncProvider.cs b/src/Purrse.Plugins.Abstractions/IAccountSyncProvider.cs new file mode 100644 index 0000000..addfb6a --- /dev/null +++ b/src/Purrse.Plugins.Abstractions/IAccountSyncProvider.cs @@ -0,0 +1,9 @@ +using Purrse.Plugins.Abstractions.Models; + +namespace Purrse.Plugins.Abstractions; + +public interface IAccountSyncProvider : IPlugin +{ + Task> GetAccountsAsync(Dictionary configuration); + Task SyncTransactionsAsync(string accountId, DateTime startDate, DateTime endDate, Dictionary configuration); +} diff --git a/src/Purrse.Plugins.Abstractions/IBankImporter.cs b/src/Purrse.Plugins.Abstractions/IBankImporter.cs new file mode 100644 index 0000000..a452578 --- /dev/null +++ b/src/Purrse.Plugins.Abstractions/IBankImporter.cs @@ -0,0 +1,10 @@ +using Purrse.Plugins.Abstractions.Models; + +namespace Purrse.Plugins.Abstractions; + +public interface IBankImporter : IPlugin +{ + string BankName { get; } + IReadOnlyList RequiredConfiguration { get; } + Task ImportAsync(Dictionary configuration); +} diff --git a/src/Purrse.Plugins.Abstractions/IFileParser.cs b/src/Purrse.Plugins.Abstractions/IFileParser.cs new file mode 100644 index 0000000..45012ec --- /dev/null +++ b/src/Purrse.Plugins.Abstractions/IFileParser.cs @@ -0,0 +1,11 @@ +using Purrse.Plugins.Abstractions.Models; + +namespace Purrse.Plugins.Abstractions; + +public interface IFileParser : IPlugin +{ + IReadOnlyList SupportedExtensions { get; } + string FileTypeDescription { get; } + bool CanParse(string fileName, Stream fileStream); + Task ParseAsync(Stream fileStream, string fileName); +} diff --git a/src/Purrse.Plugins.Abstractions/IPlugin.cs b/src/Purrse.Plugins.Abstractions/IPlugin.cs new file mode 100644 index 0000000..0fc1353 --- /dev/null +++ b/src/Purrse.Plugins.Abstractions/IPlugin.cs @@ -0,0 +1,11 @@ +namespace Purrse.Plugins.Abstractions; + +public interface IPlugin +{ + string Id { get; } + string Name { get; } + string Version { get; } + string Description { get; } + Task InitializeAsync(IPluginContext context); + Task ShutdownAsync(); +} diff --git a/src/Purrse.Plugins.Abstractions/IPluginContext.cs b/src/Purrse.Plugins.Abstractions/IPluginContext.cs new file mode 100644 index 0000000..ac2cd9e --- /dev/null +++ b/src/Purrse.Plugins.Abstractions/IPluginContext.cs @@ -0,0 +1,16 @@ +namespace Purrse.Plugins.Abstractions; + +public interface IPluginContext +{ + string PluginDataDirectory { get; } + IServiceProvider Services { get; } + void Log(string message, LogLevel level = LogLevel.Information); +} + +public enum LogLevel +{ + Debug, + Information, + Warning, + Error +} diff --git a/src/Purrse.Plugins.Abstractions/IReportGenerator.cs b/src/Purrse.Plugins.Abstractions/IReportGenerator.cs new file mode 100644 index 0000000..d1c925f --- /dev/null +++ b/src/Purrse.Plugins.Abstractions/IReportGenerator.cs @@ -0,0 +1,23 @@ +namespace Purrse.Plugins.Abstractions; + +public interface IReportGenerator : IPlugin +{ + string ReportName { get; } + string ReportDescription { get; } + Task GenerateAsync(ReportParameters parameters); +} + +public class ReportParameters +{ + public DateTime StartDate { get; set; } + public DateTime EndDate { get; set; } + public List AccountIds { get; set; } = new(); + public Dictionary CustomParameters { get; set; } = new(); +} + +public class ReportResult +{ + public string ContentType { get; set; } = "application/json"; + public byte[] Data { get; set; } = Array.Empty(); + public string FileName { get; set; } = "report"; +} diff --git a/src/Purrse.Plugins.Abstractions/ITransactionCategorizer.cs b/src/Purrse.Plugins.Abstractions/ITransactionCategorizer.cs new file mode 100644 index 0000000..00baf60 --- /dev/null +++ b/src/Purrse.Plugins.Abstractions/ITransactionCategorizer.cs @@ -0,0 +1,6 @@ +namespace Purrse.Plugins.Abstractions; + +public interface ITransactionCategorizer : IPlugin +{ + Task CategorizeAsync(string payeeName, decimal amount, string? memo); +} diff --git a/src/Purrse.Plugins.Abstractions/Models/ConfigurationField.cs b/src/Purrse.Plugins.Abstractions/Models/ConfigurationField.cs new file mode 100644 index 0000000..761be90 --- /dev/null +++ b/src/Purrse.Plugins.Abstractions/Models/ConfigurationField.cs @@ -0,0 +1,11 @@ +namespace Purrse.Plugins.Abstractions.Models; + +public class ConfigurationField +{ + public string Key { get; set; } = string.Empty; + public string Label { get; set; } = string.Empty; + public string Type { get; set; } = "text"; + public bool Required { get; set; } + public string? DefaultValue { get; set; } + public string? Description { get; set; } +} diff --git a/src/Purrse.Plugins.Abstractions/Models/ParseResult.cs b/src/Purrse.Plugins.Abstractions/Models/ParseResult.cs new file mode 100644 index 0000000..28ca530 --- /dev/null +++ b/src/Purrse.Plugins.Abstractions/Models/ParseResult.cs @@ -0,0 +1,14 @@ +namespace Purrse.Plugins.Abstractions.Models; + +public class ParseResult +{ + public bool Success { get; set; } + public string? ErrorMessage { get; set; } + public List Transactions { get; set; } = new(); + public string? AccountIdentifier { get; set; } + public string? InstitutionName { get; set; } + public DateTime? StatementStartDate { get; set; } + public DateTime? StatementEndDate { get; set; } + public decimal? LedgerBalance { get; set; } + public decimal? AvailableBalance { get; set; } +} diff --git a/src/Purrse.Plugins.Abstractions/Models/ParsedTransaction.cs b/src/Purrse.Plugins.Abstractions/Models/ParsedTransaction.cs new file mode 100644 index 0000000..aee4726 --- /dev/null +++ b/src/Purrse.Plugins.Abstractions/Models/ParsedTransaction.cs @@ -0,0 +1,13 @@ +namespace Purrse.Plugins.Abstractions.Models; + +public class ParsedTransaction +{ + public DateTime Date { get; set; } + public decimal Amount { get; set; } + public string? PayeeName { get; set; } + public string? Memo { get; set; } + public string? ReferenceNumber { get; set; } + public string? CheckNumber { get; set; } + public string? FitId { get; set; } + public string? TransactionType { get; set; } +} diff --git a/src/Purrse.Plugins.Abstractions/Models/SyncAccount.cs b/src/Purrse.Plugins.Abstractions/Models/SyncAccount.cs new file mode 100644 index 0000000..069b495 --- /dev/null +++ b/src/Purrse.Plugins.Abstractions/Models/SyncAccount.cs @@ -0,0 +1,11 @@ +namespace Purrse.Plugins.Abstractions.Models; + +public class SyncAccount +{ + public string ExternalId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string? Institution { get; set; } + public string? AccountType { get; set; } + public decimal? Balance { get; set; } + public string? Currency { get; set; } +} diff --git a/src/Purrse.Plugins.Abstractions/PluginManifest.cs b/src/Purrse.Plugins.Abstractions/PluginManifest.cs new file mode 100644 index 0000000..25f4982 --- /dev/null +++ b/src/Purrse.Plugins.Abstractions/PluginManifest.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace Purrse.Plugins.Abstractions; + +public class PluginManifest +{ + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("version")] + public string Version { get; set; } = string.Empty; + + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + + [JsonPropertyName("author")] + public string? Author { get; set; } + + [JsonPropertyName("capabilities")] + public List Capabilities { get; set; } = new(); + + [JsonPropertyName("entryAssembly")] + public string EntryAssembly { get; set; } = string.Empty; + + [JsonPropertyName("entryType")] + public string EntryType { get; set; } = string.Empty; +} diff --git a/src/Purrse.Plugins.Abstractions/Purrse.Plugins.Abstractions.csproj b/src/Purrse.Plugins.Abstractions/Purrse.Plugins.Abstractions.csproj new file mode 100644 index 0000000..1825e6b --- /dev/null +++ b/src/Purrse.Plugins.Abstractions/Purrse.Plugins.Abstractions.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + diff --git a/src/Purrse.Plugins.CSV/CSVParserPlugin.cs b/src/Purrse.Plugins.CSV/CSVParserPlugin.cs new file mode 100644 index 0000000..a0d4fae --- /dev/null +++ b/src/Purrse.Plugins.CSV/CSVParserPlugin.cs @@ -0,0 +1,159 @@ +using System.Globalization; +using CsvHelper; +using CsvHelper.Configuration; +using Purrse.Plugins.Abstractions; +using Purrse.Plugins.Abstractions.Models; + +namespace Purrse.Plugins.CSV; + +public class CSVParserPlugin : IFileParser +{ + public string Id => "purrse-csv-parser"; + public string Name => "CSV Parser"; + public string Version => "1.0.0"; + public string Description => "Parses CSV bank statement files with configurable column mapping"; + public IReadOnlyList SupportedExtensions => new[] { ".csv" }; + public string FileTypeDescription => "CSV Bank Statement Files"; + + public Task InitializeAsync(IPluginContext context) => Task.CompletedTask; + public Task ShutdownAsync() => Task.CompletedTask; + + public bool CanParse(string fileName, Stream fileStream) + { + return Path.GetExtension(fileName).Equals(".csv", StringComparison.OrdinalIgnoreCase); + } + + public async Task ParseAsync(Stream fileStream, string fileName) + { + var result = new ParseResult { Success = true }; + + try + { + using var reader = new StreamReader(fileStream); + using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) + { + HasHeaderRecord = true, + MissingFieldFound = null, + HeaderValidated = null + }); + + await csv.ReadAsync(); + csv.ReadHeader(); + var headers = csv.HeaderRecord?.Select(h => h.Trim().ToLowerInvariant()).ToArray() ?? Array.Empty(); + + var mapping = DetectColumnMapping(headers); + + while (await csv.ReadAsync()) + { + try + { + var transaction = new ParsedTransaction(); + + if (mapping.DateColumn >= 0) + { + var dateStr = csv.GetField(mapping.DateColumn); + if (DateTime.TryParse(dateStr, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) + transaction.Date = date; + } + + if (mapping.AmountColumn >= 0) + { + var amountStr = csv.GetField(mapping.AmountColumn)?.Replace("$", "").Replace(",", "").Trim(); + if (decimal.TryParse(amountStr, NumberStyles.Any, CultureInfo.InvariantCulture, out var amount)) + transaction.Amount = amount; + } + else + { + // Separate debit/credit columns + decimal amt = 0; + if (mapping.DebitColumn >= 0) + { + var debitStr = csv.GetField(mapping.DebitColumn)?.Replace("$", "").Replace(",", "").Trim(); + if (!string.IsNullOrEmpty(debitStr) && decimal.TryParse(debitStr, NumberStyles.Any, CultureInfo.InvariantCulture, out var debit)) + amt = -Math.Abs(debit); + } + if (mapping.CreditColumn >= 0 && amt == 0) + { + var creditStr = csv.GetField(mapping.CreditColumn)?.Replace("$", "").Replace(",", "").Trim(); + if (!string.IsNullOrEmpty(creditStr) && decimal.TryParse(creditStr, NumberStyles.Any, CultureInfo.InvariantCulture, out var credit)) + amt = Math.Abs(credit); + } + transaction.Amount = amt; + } + + if (mapping.DescriptionColumn >= 0) + transaction.PayeeName = csv.GetField(mapping.DescriptionColumn)?.Trim(); + + if (mapping.MemoColumn >= 0) + transaction.Memo = csv.GetField(mapping.MemoColumn)?.Trim(); + + if (mapping.ReferenceColumn >= 0) + transaction.ReferenceNumber = csv.GetField(mapping.ReferenceColumn)?.Trim(); + + if (mapping.CheckColumn >= 0) + transaction.CheckNumber = csv.GetField(mapping.CheckColumn)?.Trim(); + + if (mapping.TypeColumn >= 0) + transaction.TransactionType = csv.GetField(mapping.TypeColumn)?.Trim(); + + if (transaction.Date != default) + result.Transactions.Add(transaction); + } + catch + { + // Skip malformed rows + } + } + } + catch (Exception ex) + { + result.Success = false; + result.ErrorMessage = $"Failed to parse CSV: {ex.Message}"; + } + + return result; + } + + private static ColumnMapping DetectColumnMapping(string[] headers) + { + var mapping = new ColumnMapping(); + + for (int i = 0; i < headers.Length; i++) + { + var h = headers[i]; + if (h.Contains("date") || h.Contains("posted")) + mapping.DateColumn = i; + else if (h == "amount" || h.Contains("transaction amount")) + mapping.AmountColumn = i; + else if (h.Contains("debit") || h.Contains("withdrawal")) + mapping.DebitColumn = i; + else if (h.Contains("credit") || h.Contains("deposit")) + mapping.CreditColumn = i; + else if (h.Contains("description") || h.Contains("payee") || h.Contains("name") || h.Contains("merchant")) + mapping.DescriptionColumn = i; + else if (h.Contains("memo") || h.Contains("note")) + mapping.MemoColumn = i; + else if (h.Contains("reference") || h.Contains("ref")) + mapping.ReferenceColumn = i; + else if (h.Contains("check") || h.Contains("cheque")) + mapping.CheckColumn = i; + else if (h.Contains("type") || h.Contains("category")) + mapping.TypeColumn = i; + } + + return mapping; + } + + private class ColumnMapping + { + public int DateColumn { get; set; } = -1; + public int AmountColumn { get; set; } = -1; + public int DebitColumn { get; set; } = -1; + public int CreditColumn { get; set; } = -1; + public int DescriptionColumn { get; set; } = -1; + public int MemoColumn { get; set; } = -1; + public int ReferenceColumn { get; set; } = -1; + public int CheckColumn { get; set; } = -1; + public int TypeColumn { get; set; } = -1; + } +} diff --git a/src/Purrse.Plugins.CSV/Purrse.Plugins.CSV.csproj b/src/Purrse.Plugins.CSV/Purrse.Plugins.CSV.csproj new file mode 100644 index 0000000..5caccfc --- /dev/null +++ b/src/Purrse.Plugins.CSV/Purrse.Plugins.CSV.csproj @@ -0,0 +1,17 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + diff --git a/src/Purrse.Plugins.OFX/OFXParserPlugin.cs b/src/Purrse.Plugins.OFX/OFXParserPlugin.cs new file mode 100644 index 0000000..fcd59af --- /dev/null +++ b/src/Purrse.Plugins.OFX/OFXParserPlugin.cs @@ -0,0 +1,234 @@ +using Purrse.Plugins.Abstractions; +using Purrse.Plugins.Abstractions.Models; + +namespace Purrse.Plugins.OFX; + +public class OFXParserPlugin : IFileParser +{ + public string Id => "purrse-ofx-parser"; + public string Name => "OFX/QFX Parser"; + public string Version => "1.0.0"; + public string Description => "Parses OFX and QFX (Quicken) financial data files"; + public IReadOnlyList SupportedExtensions => new[] { ".ofx", ".qfx" }; + public string FileTypeDescription => "OFX/QFX Financial Data Files"; + + public Task InitializeAsync(IPluginContext context) => Task.CompletedTask; + public Task ShutdownAsync() => Task.CompletedTask; + + public bool CanParse(string fileName, Stream fileStream) + { + var ext = Path.GetExtension(fileName).ToLowerInvariant(); + return ext == ".ofx" || ext == ".qfx"; + } + + public async Task ParseAsync(Stream fileStream, string fileName) + { + try + { + using var reader = new StreamReader(fileStream); + var content = await reader.ReadToEndAsync(); + return ParseOFXContent(content); + } + catch (Exception ex) + { + return new ParseResult { Success = false, ErrorMessage = $"Failed to parse OFX file: {ex.Message}" }; + } + } + + private ParseResult ParseOFXContent(string content) + { + var result = new ParseResult { Success = true }; + + // Handle SGML-style OFX (v1.x) by converting to XML-like format + if (!content.TrimStart().StartsWith("(); + var openTags = new Stack(); + bool inHeader = true; + + xmlLines.Add(""); + + foreach (var rawLine in lines) + { + var line = rawLine.Trim(); + if (string.IsNullOrEmpty(line)) continue; + + // Skip OFX headers (before the first <) + if (inHeader) + { + if (line.StartsWith('<')) + inHeader = false; + else + continue; + } + + if (line.StartsWith(" 0) openTags.Pop(); + } + else if (line.StartsWith('<')) + { + // Check if it's a tag with value on same line: value + var tagEnd = line.IndexOf('>'); + if (tagEnd > 0 && tagEnd < line.Length - 1) + { + var tag = line.Substring(1, tagEnd - 1); + var value = line.Substring(tagEnd + 1).Trim(); + if (!string.IsNullOrEmpty(value)) + { + // Self-contained: value -> value + xmlLines.Add($"<{tag}>{System.Security.SecurityElement.Escape(value)}"); + } + else + { + // Opening tag with no value + xmlLines.Add(line); + openTags.Push(tag); + } + } + else + { + xmlLines.Add(line); + if (tagEnd == line.Length - 1) + { + var tag = line.Substring(1, tagEnd - 1); + if (!tag.StartsWith('/')) + openTags.Push(tag); + } + } + } + } + + return string.Join("\n", xmlLines); + } + + private static DateTime ParseOFXDate(string ofxDate) + { + // OFX dates: YYYYMMDDHHMMSS[.XXX:tz] or YYYYMMDD + if (ofxDate.Length >= 8) + { + var datePart = ofxDate.Substring(0, 8); + if (DateTime.TryParseExact(datePart, "yyyyMMdd", + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, out var date)) + return date; + } + return DateTime.Parse(ofxDate); + } +} diff --git a/src/Purrse.Plugins.OFX/Purrse.Plugins.OFX.csproj b/src/Purrse.Plugins.OFX/Purrse.Plugins.OFX.csproj new file mode 100644 index 0000000..9f88f00 --- /dev/null +++ b/src/Purrse.Plugins.OFX/Purrse.Plugins.OFX.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + diff --git a/src/Purrse.Plugins.QIF/Purrse.Plugins.QIF.csproj b/src/Purrse.Plugins.QIF/Purrse.Plugins.QIF.csproj new file mode 100644 index 0000000..9f88f00 --- /dev/null +++ b/src/Purrse.Plugins.QIF/Purrse.Plugins.QIF.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + diff --git a/src/Purrse.Plugins.QIF/QIFParserPlugin.cs b/src/Purrse.Plugins.QIF/QIFParserPlugin.cs new file mode 100644 index 0000000..63be87c --- /dev/null +++ b/src/Purrse.Plugins.QIF/QIFParserPlugin.cs @@ -0,0 +1,110 @@ +using Purrse.Plugins.Abstractions; +using Purrse.Plugins.Abstractions.Models; + +namespace Purrse.Plugins.QIF; + +public class QIFParserPlugin : IFileParser +{ + public string Id => "purrse-qif-parser"; + public string Name => "QIF Parser"; + public string Version => "1.0.0"; + public string Description => "Parses QIF (Quicken Interchange Format) files"; + public IReadOnlyList SupportedExtensions => new[] { ".qif" }; + public string FileTypeDescription => "QIF Financial Data Files"; + + public Task InitializeAsync(IPluginContext context) => Task.CompletedTask; + public Task ShutdownAsync() => Task.CompletedTask; + + public bool CanParse(string fileName, Stream fileStream) + { + return Path.GetExtension(fileName).Equals(".qif", StringComparison.OrdinalIgnoreCase); + } + + public async Task ParseAsync(Stream fileStream, string fileName) + { + var result = new ParseResult { Success = true }; + + try + { + using var reader = new StreamReader(fileStream); + var content = await reader.ReadToEndAsync(); + var lines = content.Split('\n').Select(l => l.Trim('\r', ' ')).ToArray(); + + ParsedTransaction? current = null; + + foreach (var line in lines) + { + if (string.IsNullOrEmpty(line)) continue; + + var code = line[0]; + var value = line.Length > 1 ? line[1..] : ""; + + switch (code) + { + case '!': + // Header line - type declaration + break; + case 'D': + current ??= new ParsedTransaction(); + if (DateTime.TryParse(value, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, out var date)) + current.Date = date; + else if (TryParseQIFDate(value, out var qifDate)) + current.Date = qifDate; + break; + case 'T': + case 'U': + current ??= new ParsedTransaction(); + var amountStr = value.Replace(",", ""); + if (decimal.TryParse(amountStr, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var amount)) + current.Amount = amount; + break; + case 'P': + current ??= new ParsedTransaction(); + current.PayeeName = value; + break; + case 'M': + current ??= new ParsedTransaction(); + current.Memo = value; + break; + case 'N': + current ??= new ParsedTransaction(); + current.CheckNumber = value; + break; + case 'L': + // Category - stored in memo for now + if (current != null && string.IsNullOrEmpty(current.Memo)) + current.Memo = $"[{value}]"; + break; + case '^': + if (current != null && current.Date != default) + { + result.Transactions.Add(current); + } + current = null; + break; + } + } + } + catch (Exception ex) + { + result.Success = false; + result.ErrorMessage = $"Failed to parse QIF: {ex.Message}"; + } + + return result; + } + + private static bool TryParseQIFDate(string value, out DateTime date) + { + // QIF dates can be M/D/YY, M/D'YY, M-D-YY, etc. + date = default; + var cleaned = value.Replace("'", "/").Replace("-", "/"); + string[] formats = { "M/d/yyyy", "M/d/yy", "MM/dd/yyyy", "MM/dd/yy", "d/M/yyyy", "d/M/yy" }; + + return DateTime.TryParseExact(cleaned, formats, + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, out date); + } +} diff --git a/src/Purrse.Tests/Helpers/StringSimilarityTests.cs b/src/Purrse.Tests/Helpers/StringSimilarityTests.cs new file mode 100644 index 0000000..1fed738 --- /dev/null +++ b/src/Purrse.Tests/Helpers/StringSimilarityTests.cs @@ -0,0 +1,50 @@ +using FluentAssertions; +using Purrse.Core.Helpers; + +namespace Purrse.Tests.Helpers; + +public class StringSimilarityTests +{ + [Theory] + [InlineData("WALMART", "WALMART", 1.0)] + [InlineData("WAL-MART STORE #1234", "WALMART", 0.7)] + [InlineData("AMAZON.COM", "AMAZON MARKETPLACE", 0.6)] + public void Calculate_KnownPairs_ReturnsExpectedRange(string s1, string s2, double minExpected) + { + var result = StringSimilarityHelper.Calculate(s1, s2); + result.Should().BeGreaterOrEqualTo(minExpected); + } + + [Fact] + public void Calculate_ExactMatch_ReturnsOne() + { + StringSimilarityHelper.Calculate("Test Payee", "Test Payee").Should().Be(1.0); + } + + [Fact] + public void Calculate_CaseInsensitive() + { + var result = StringSimilarityHelper.Calculate("walmart", "WALMART"); + result.Should().Be(1.0); + } + + [Fact] + public void Calculate_EmptyString_ReturnsZero() + { + StringSimilarityHelper.Calculate("", "test").Should().Be(0); + StringSimilarityHelper.Calculate("test", "").Should().Be(0); + } + + [Fact] + public void Calculate_NullString_ReturnsZero() + { + StringSimilarityHelper.Calculate(null!, "test").Should().Be(0); + } + + [Fact] + public void Calculate_CompletelyDifferent_ReturnsLowScore() + { + var result = StringSimilarityHelper.Calculate("ABCDEF", "ZYXWVU"); + result.Should().BeLessThan(0.5); + } +} diff --git a/src/Purrse.Tests/Helpers/TransactionFingerprintTests.cs b/src/Purrse.Tests/Helpers/TransactionFingerprintTests.cs new file mode 100644 index 0000000..1b0b469 --- /dev/null +++ b/src/Purrse.Tests/Helpers/TransactionFingerprintTests.cs @@ -0,0 +1,56 @@ +using FluentAssertions; +using Purrse.Core.Helpers; + +namespace Purrse.Tests.Helpers; + +public class TransactionFingerprintTests +{ + [Fact] + public void Generate_SameInputs_ReturnsSameHash() + { + var accountId = Guid.NewGuid(); + var date = new DateTime(2024, 1, 15); + var amount = -42.50m; + var fitId = "FIT123"; + + var hash1 = TransactionFingerprint.Generate(accountId, date, amount, fitId); + var hash2 = TransactionFingerprint.Generate(accountId, date, amount, fitId); + + hash1.Should().Be(hash2); + } + + [Fact] + public void Generate_DifferentAmounts_ReturnsDifferentHash() + { + var accountId = Guid.NewGuid(); + var date = new DateTime(2024, 1, 15); + + var hash1 = TransactionFingerprint.Generate(accountId, date, -42.50m, "FIT123"); + var hash2 = TransactionFingerprint.Generate(accountId, date, -42.51m, "FIT123"); + + hash1.Should().NotBe(hash2); + } + + [Fact] + public void Generate_NullFitId_StillWorks() + { + var accountId = Guid.NewGuid(); + var date = new DateTime(2024, 1, 15); + + var hash = TransactionFingerprint.Generate(accountId, date, -42.50m, null); + + hash.Should().NotBeNullOrEmpty(); + hash.Should().HaveLength(64); // SHA256 hex = 64 chars + } + + [Fact] + public void Generate_DifferentAccounts_ReturnsDifferentHash() + { + var date = new DateTime(2024, 1, 15); + + var hash1 = TransactionFingerprint.Generate(Guid.NewGuid(), date, -42.50m, null); + var hash2 = TransactionFingerprint.Generate(Guid.NewGuid(), date, -42.50m, null); + + hash1.Should().NotBe(hash2); + } +} diff --git a/src/Purrse.Tests/Purrse.Tests.csproj b/src/Purrse.Tests/Purrse.Tests.csproj new file mode 100644 index 0000000..f1b03a2 --- /dev/null +++ b/src/Purrse.Tests/Purrse.Tests.csproj @@ -0,0 +1,34 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Purrse.Tests/Services/DuplicateDetectionServiceTests.cs b/src/Purrse.Tests/Services/DuplicateDetectionServiceTests.cs new file mode 100644 index 0000000..7f5d1ef --- /dev/null +++ b/src/Purrse.Tests/Services/DuplicateDetectionServiceTests.cs @@ -0,0 +1,118 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Purrse.Api.Services; +using Purrse.Core.Enums; +using Purrse.Core.Helpers; +using Purrse.Core.Models; +using Purrse.Data; + +namespace Purrse.Tests.Services; + +public class DuplicateDetectionServiceTests +{ + private PurrseDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new PurrseDbContext(options); + } + + [Fact] + public void CalculateConfidence_ExactMatch_ReturnsHighScore() + { + using var db = CreateContext(); + var service = new DuplicateDetectionService(db); + + var existing = new Transaction + { + Id = Guid.NewGuid(), + Amount = -42.50m, + Date = new DateTime(2024, 1, 15), + PayeeName = "WALMART STORE #1234", + CheckNumber = "1001" + }; + + var score = service.CalculateConfidence(existing, new DateTime(2024, 1, 15), -42.50m, "WALMART STORE #1234", null, "1001"); + + score.Should().BeGreaterOrEqualTo(0.85, "exact match on amount + date + payee + check# should auto-match"); + } + + [Fact] + public void CalculateConfidence_DifferentAmount_ReturnsLowScore() + { + using var db = CreateContext(); + var service = new DuplicateDetectionService(db); + + var existing = new Transaction + { + Id = Guid.NewGuid(), + Amount = -42.50m, + Date = new DateTime(2024, 1, 15), + PayeeName = "WALMART" + }; + + var score = service.CalculateConfidence(existing, new DateTime(2024, 1, 15), -100.00m, "TARGET", null, null); + + score.Should().BeLessThan(0.50, "different amount and payee should not match"); + } + + [Fact] + public void CalculateConfidence_SameAmountDifferentDay_ReturnsMediumScore() + { + using var db = CreateContext(); + var service = new DuplicateDetectionService(db); + + var existing = new Transaction + { + Id = Guid.NewGuid(), + Amount = -42.50m, + Date = new DateTime(2024, 1, 15), + PayeeName = "WALMART" + }; + + var score = service.CalculateConfidence(existing, new DateTime(2024, 1, 16), -42.50m, "WALMART", null, null); + + // Amount match (0.40) + 1-day date (0.20) + payee match (0.20) = 0.80 + score.Should().BeGreaterOrEqualTo(0.50); + } + + [Fact] + public async Task FindDuplicateAsync_ExactFingerprint_ReturnsMatch() + { + using var db = CreateContext(); + var accountId = Guid.NewGuid(); + var date = new DateTime(2024, 1, 15); + var amount = -42.50m; + var fitId = "FIT123"; + + var fingerprint = TransactionFingerprint.Generate(accountId, date, amount, fitId); + + db.Transactions.Add(new Transaction + { + Id = Guid.NewGuid(), + AccountId = accountId, + Date = date, + Amount = amount, + FitId = fitId, + Fingerprint = fingerprint + }); + await db.SaveChangesAsync(); + + var service = new DuplicateDetectionService(db); + var match = await service.FindDuplicateAsync(accountId, date, amount, null, fitId, null); + + match.Should().NotBeNull(); + match!.Confidence.Should().Be(1.0); + } + + [Fact] + public async Task FindDuplicateAsync_NoMatch_ReturnsNull() + { + using var db = CreateContext(); + var service = new DuplicateDetectionService(db); + + var match = await service.FindDuplicateAsync(Guid.NewGuid(), DateTime.Now, -10m, "TEST", null, null); + match.Should().BeNull(); + } +}