Private
Public Access
1
0

Initial commit: Purrse personal finance app

Self-hosted, plugin-extensible personal finance manager built with
ASP.NET Core 9.0, Vue 3 + Vuetify 3, and PostgreSQL 17.

Backend (8 .NET projects):
- Core: 19 domain models, 6 enums, 14 DTOs, 12 service interfaces
- Data: EF Core DbContext, 16 entity configurations, category seeder
- API: 14 controllers, 15 services, JWT auth, SignalR, middleware
- Plugins: Abstractions + OFX/CSV/QIF file parsers
- Tests: 28 xUnit tests (fingerprinting, duplicate detection, parsers)

Frontend (Vue 3 + Vuetify 3 + TypeScript):
- 13 views, Pinia stores, Axios API services with JWT interceptors
- Dashboard, accounts, transactions, categories, imports, and more

Deployment:
- Docker Compose (PostgreSQL 17 + .NET API + nginx frontend)
- Auto-migration on startup

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

Some files were not shown because too many files have changed in this diff Show More