feat(finance): add complete finance app with budget tracking

- Backend (NestJS, Port 3019):
  - Account management with multi-currency support
  - Categories with income/expense types and seed functionality
  - Transaction tracking with comprehensive filters
  - Monthly budgets with spending tracking
  - Transfers between accounts
  - Reports (dashboard, trends, category breakdown)
  - User settings (currency, locale, date format)
  - Exchange rates with ECB API integration
  - Bank sync architecture prepared (connected_accounts)

- Frontend (SvelteKit, Port 5189):
  - Dashboard with totals, budget progress, recent transactions
  - Transaction list with filters
  - Account management
  - Category management
  - Budget tracking per month
  - Reports & trends
  - Settings page
  - Auth pages (login, register, forgot-password)

- Shared package with types, constants, utilities
- Environment configuration for backend, web, mobile

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-04 16:41:43 +01:00
parent bbe540c3f1
commit ad0051a8fc
109 changed files with 10502 additions and 0 deletions

320
apps/finance/CLAUDE.md Normal file
View file

@ -0,0 +1,320 @@
# Finance Project Guide
## Overview
**Finance** is a personal finance and budget tracking application for the ManaCore ecosystem. It supports multi-currency accounts, expense/income tracking, budgets, reports, and prepares for future bank synchronization.
| App | Port | URL |
|-----|------|-----|
| Backend | 3019 | http://localhost:3019 |
| Web App | 5189 | http://localhost:5189 |
| Landing Page | 4324 | http://localhost:4324 |
## Project Structure
```
apps/finance/
├── apps/
│ ├── backend/ # NestJS API server (@finance/backend)
│ ├── web/ # SvelteKit web application (@finance/web)
│ ├── mobile/ # Expo React Native app (@finance/mobile)
│ └── landing/ # Astro marketing landing page (@finance/landing)
├── packages/
│ └── shared/ # Shared types, utils, constants (@finance/shared)
├── package.json
└── CLAUDE.md
```
## Commands
### Root Level (from monorepo root)
```bash
# All apps
pnpm finance:dev # Run all finance apps
# Individual apps
pnpm dev:finance:backend # Start backend server (port 3019)
pnpm dev:finance:web # Start web app (port 5189)
pnpm dev:finance:mobile # Start mobile app
pnpm dev:finance:landing # Start landing page (port 4324)
pnpm dev:finance:app # Start web + backend together
# Database
pnpm finance:db:push # Push schema to database
pnpm finance:db:studio # Open Drizzle Studio
pnpm finance:db:seed # Seed initial data (default categories)
```
### Backend (apps/finance/apps/backend)
```bash
pnpm dev # Start with hot reload
pnpm build # Build for production
pnpm start:prod # Start production server
pnpm db:push # Push schema to database
pnpm db:studio # Open Drizzle Studio
pnpm db:seed # Seed initial data
```
### Web App (apps/finance/apps/web)
```bash
pnpm dev # Start dev server (port 5189)
pnpm build # Build for production
pnpm preview # Preview production build
```
## Technology Stack
| Layer | Technology |
|-------|------------|
| **Backend** | NestJS 10, Drizzle ORM, PostgreSQL |
| **Web** | SvelteKit 2.x, Svelte 5 (runes mode), Tailwind CSS 4 |
| **Mobile** | Expo, React Native, NativeWind |
| **Landing** | Astro 5.x, Tailwind CSS |
| **Auth** | Mana Core Auth (JWT) |
| **Charts** | Chart.js with svelte-chartjs |
| **i18n** | svelte-i18n (DE, EN) |
| **Dates** | date-fns |
## Core Features
1. **Accounts** - Multiple accounts (checking, savings, credit card, cash, investment)
2. **Categories** - Income/expense categories with colors and icons
3. **Transactions** - Full CRUD with filtering, search, recurring support
4. **Budgets** - Monthly budget limits per category with alerts
5. **Transfers** - Move money between accounts
6. **Reports** - Dashboard, monthly summaries, trends, category breakdown
7. **Multi-Currency** - Support for multiple currencies with exchange rates
8. **Bank Sync (Prepared)** - Architecture ready for Plaid/GoCardless integration
## Views
| View | Route | Description |
|------|-------|-------------|
| **Dashboard** | `/` | Overview with totals, budget progress, recent transactions |
| **Transactions** | `/transactions` | All transactions with filters |
| **Accounts** | `/accounts` | Account list and management |
| **Account Detail** | `/accounts/[id]` | Account transactions and details |
| **Categories** | `/categories` | Category management |
| **Budgets** | `/budgets` | Budget setup and tracking |
| **Reports** | `/reports` | Report overview |
| **Monthly Report** | `/reports/monthly` | Monthly income/expense breakdown |
| **Trends** | `/reports/trends` | Spending trends over time |
| **Settings** | `/settings` | User preferences, currency, locale |
## API Endpoints
### Accounts
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/accounts` | GET | List user's accounts |
| `/api/v1/accounts` | POST | Create account |
| `/api/v1/accounts/:id` | GET | Get account details |
| `/api/v1/accounts/:id` | PUT | Update account |
| `/api/v1/accounts/:id` | DELETE | Delete account |
| `/api/v1/accounts/:id/archive` | POST | Archive/unarchive |
| `/api/v1/accounts/totals` | GET | Get totals by currency |
| `/api/v1/accounts/reorder` | PUT | Reorder accounts |
### Categories
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/categories` | GET | List categories (filter: type) |
| `/api/v1/categories` | POST | Create category |
| `/api/v1/categories/:id` | PUT | Update category |
| `/api/v1/categories/:id` | DELETE | Delete category |
| `/api/v1/categories/seed` | POST | Seed default categories |
### Transactions
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/transactions` | GET | Query transactions (filters) |
| `/api/v1/transactions` | POST | Create transaction |
| `/api/v1/transactions/:id` | GET | Get transaction details |
| `/api/v1/transactions/:id` | PUT | Update transaction |
| `/api/v1/transactions/:id` | DELETE | Delete transaction |
| `/api/v1/transactions/recent` | GET | Recent transactions |
**Query Parameters:**
- `accountId` - Filter by account
- `categoryId` - Filter by category
- `type` - income/expense
- `startDate`, `endDate` - Date range
- `minAmount`, `maxAmount` - Amount range
- `search` - Search description/payee
- `isPending` - Pending only
- `isRecurring` - Recurring only
- `limit`, `offset` - Pagination
### Budgets
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/budgets` | GET | List budgets |
| `/api/v1/budgets` | POST | Create/update budget |
| `/api/v1/budgets/:id` | PUT | Update budget |
| `/api/v1/budgets/:id` | DELETE | Delete budget |
| `/api/v1/budgets/month/:year/:month` | GET | Budgets with spending |
| `/api/v1/budgets/copy` | POST | Copy from previous month |
### Transfers
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/transfers` | GET | List transfers |
| `/api/v1/transfers` | POST | Create transfer |
| `/api/v1/transfers/:id` | PUT | Update transfer |
| `/api/v1/transfers/:id` | DELETE | Delete transfer |
### Reports
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/reports/dashboard` | GET | Dashboard aggregations |
| `/api/v1/reports/monthly-summary` | GET | Monthly income/expense |
| `/api/v1/reports/category-breakdown` | GET | Spending by category |
| `/api/v1/reports/trends` | GET | Trends over time |
| `/api/v1/reports/cash-flow` | GET | Cash flow analysis |
## Database Schema
### accounts
- `id` (UUID) - Primary key
- `user_id` (UUID) - Owner
- `name` (VARCHAR) - Account name
- `type` (VARCHAR) - checking/savings/credit_card/cash/investment/loan
- `balance` (DECIMAL) - Current balance
- `currency` (VARCHAR) - Currency code (EUR, USD, etc.)
- `color`, `icon` - Display options
- `is_archived` - Soft delete
- `include_in_total` - Include in dashboard totals
### categories
- `id` (UUID) - Primary key
- `user_id` (UUID) - Owner
- `name` (VARCHAR) - Category name
- `type` (VARCHAR) - income/expense
- `parent_id` (UUID) - For subcategories
- `color`, `icon` - Display options
- `is_system` - Default categories
- `is_archived` - Soft delete
### transactions
- `id` (UUID) - Primary key
- `user_id` (UUID) - Owner
- `account_id` (UUID) - FK to accounts
- `category_id` (UUID) - FK to categories
- `type` (VARCHAR) - income/expense
- `amount` (DECIMAL) - Transaction amount
- `currency` (VARCHAR) - Currency code
- `date` (DATE) - Transaction date
- `description` (TEXT) - Description
- `payee` (VARCHAR) - Payee/payer name
- `is_recurring` (BOOLEAN) - Recurring flag
- `recurrence_rule` (JSONB) - Recurrence pattern
- `is_pending` (BOOLEAN) - Pending flag
- `tags` (JSONB) - Tag array
### budgets
- `id` (UUID) - Primary key
- `user_id` (UUID) - Owner
- `category_id` (UUID) - FK to categories (null = overall)
- `month`, `year` (INTEGER) - Budget period
- `amount` (DECIMAL) - Budget limit
- `alert_threshold` (DECIMAL) - Alert at percentage
- `rollover_enabled` (BOOLEAN) - Carry unused budget
### transfers
- `id` (UUID) - Primary key
- `user_id` (UUID) - Owner
- `from_account_id`, `to_account_id` (UUID) - Account references
- `amount` (DECIMAL) - Transfer amount
- `date` (DATE) - Transfer date
### exchange_rates
- `id` (UUID) - Primary key
- `from_currency`, `to_currency` (VARCHAR) - Currency pair
- `rate` (DECIMAL) - Exchange rate
- `date` (DATE) - Rate date
### user_settings
- `id` (UUID) - Primary key
- `user_id` (UUID) - Owner
- `default_currency` (VARCHAR) - Default currency
- `locale` (VARCHAR) - User locale
- `date_format` (VARCHAR) - Preferred date format
### connected_accounts (Bank Sync Preparation)
- `id` (UUID) - Primary key
- `user_id` (UUID) - Owner
- `account_id` (UUID) - FK to accounts
- `provider` (VARCHAR) - plaid/gocardless/etc.
- `external_id` (VARCHAR) - Provider account ID
- `status` (VARCHAR) - active/disconnected/error
- `last_sync_at` (TIMESTAMP) - Last sync time
- `metadata` (JSONB) - Provider-specific data
## Environment Variables
### Backend (.env)
```env
NODE_ENV=development
PORT=3019
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/finance
MANA_CORE_AUTH_URL=http://localhost:3001
CORS_ORIGINS=http://localhost:5173,http://localhost:5189,http://localhost:8081
```
### Web (.env)
```env
PUBLIC_BACKEND_URL=http://localhost:3019
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
```
## Default Categories (Seeded)
### Expense
- Lebensmittel (Groceries) - green
- Restaurant (Dining) - orange
- Transport - blue
- Wohnen (Housing) - purple
- Versicherungen (Insurance) - gray
- Gesundheit (Health) - red
- Unterhaltung (Entertainment) - pink
- Shopping - yellow
- Bildung (Education) - indigo
- Reisen (Travel) - cyan
- Abonnements (Subscriptions) - violet
- Sonstiges (Other) - gray
### Income
- Gehalt (Salary) - green
- Nebeneinkommen (Side Income) - blue
- Investitionen (Investments) - purple
- Geschenke (Gifts) - pink
- Sonstiges (Other) - gray
## Code Style Guidelines
- **TypeScript**: Strict typing with interfaces
- **Web**: Svelte 5 runes mode (`$state`, `$derived`, `$effect`)
- **Styling**: Tailwind CSS with CSS variables
- **Formatting**: Prettier with project config
- **i18n**: All UI text in locale files
- **Currency**: Always use DECIMAL(15,2) for money
## Important Notes
1. **Authentication**: Uses Mana Core Auth (JWT in Authorization header)
2. **Database**: PostgreSQL with Drizzle ORM (port 5432)
3. **Ports**: Backend=3019, Web=5189, Landing=4324
4. **Multi-Currency**: Exchange rates table for conversions
5. **Bank Sync**: Architecture prepared, implementation deferred
6. **Balance Updates**: Transactions automatically update account balances

View file

@ -0,0 +1,13 @@
import { defineConfig } from 'drizzle-kit';
import * as dotenv from 'dotenv';
dotenv.config();
export default defineConfig({
schema: './src/db/schema/index.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});

View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View file

@ -0,0 +1,42 @@
{
"name": "@finance/backend",
"version": "1.0.0",
"private": true,
"description": "Finance Backend API",
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
"start": "nest start",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts",
"db:generate": "drizzle-kit generate"
},
"dependencies": {
"@finance/shared": "workspace:*",
"@manacore/shared-nestjs-auth": "workspace:*",
"@nestjs/common": "^10.4.9",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.9",
"@nestjs/platform-express": "^10.4.9",
"@nestjs/schedule": "^4.1.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.38.3",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@types/express": "^5.0.1",
"@types/node": "^22.15.21",
"drizzle-kit": "^0.30.2",
"tsx": "^4.19.4",
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,74 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, type CurrentUserData } from '@manacore/shared-nestjs-auth';
import { AccountService } from './account.service';
import { CreateAccountDto, UpdateAccountDto } from './dto';
@Controller('accounts')
@UseGuards(JwtAuthGuard)
export class AccountController {
constructor(private readonly accountService: AccountService) {}
@Get()
findAll(@CurrentUser() user: CurrentUserData) {
return this.accountService.findAll(user.userId);
}
@Get('all')
findAllIncludingArchived(@CurrentUser() user: CurrentUserData) {
return this.accountService.findAllIncludingArchived(user.userId);
}
@Get('totals')
getTotals(@CurrentUser() user: CurrentUserData) {
return this.accountService.getTotals(user.userId);
}
@Get(':id')
findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
return this.accountService.findOne(user.userId, id);
}
@Post()
create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateAccountDto) {
return this.accountService.create(user.userId, dto);
}
@Put('reorder')
reorder(@CurrentUser() user: CurrentUserData, @Body('accountIds') accountIds: string[]) {
return this.accountService.reorder(user.userId, accountIds);
}
@Put(':id')
update(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateAccountDto
) {
return this.accountService.update(user.userId, id, dto);
}
@Post(':id/archive')
archive(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
return this.accountService.archive(user.userId, id, true);
}
@Post(':id/unarchive')
unarchive(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
return this.accountService.archive(user.userId, id, false);
}
@Delete(':id')
delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
return this.accountService.delete(user.userId, id);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AccountController } from './account.controller';
import { AccountService } from './account.service';
@Module({
controllers: [AccountController],
providers: [AccountService],
exports: [AccountService],
})
export class AccountModule {}

View file

@ -0,0 +1,155 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and, asc, sql } from 'drizzle-orm';
import { DATABASE_CONNECTION, type Database } from '../db/connection';
import { accounts } from '../db/schema';
import { CreateAccountDto, UpdateAccountDto } from './dto';
@Injectable()
export class AccountService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findAll(userId: string) {
return this.db
.select()
.from(accounts)
.where(and(eq(accounts.userId, userId), eq(accounts.isArchived, false)))
.orderBy(asc(accounts.order), asc(accounts.createdAt));
}
async findAllIncludingArchived(userId: string) {
return this.db
.select()
.from(accounts)
.where(eq(accounts.userId, userId))
.orderBy(asc(accounts.order), asc(accounts.createdAt));
}
async findOne(userId: string, id: string) {
const [account] = await this.db
.select()
.from(accounts)
.where(and(eq(accounts.id, id), eq(accounts.userId, userId)));
if (!account) {
throw new NotFoundException(`Account with ID ${id} not found`);
}
return account;
}
async create(userId: string, dto: CreateAccountDto) {
// Get the highest order value
const [maxOrder] = await this.db
.select({ maxOrder: sql<number>`COALESCE(MAX(${accounts.order}), 0)` })
.from(accounts)
.where(eq(accounts.userId, userId));
const [account] = await this.db
.insert(accounts)
.values({
userId,
name: dto.name,
type: dto.type,
balance: dto.balance?.toString() ?? '0',
currency: dto.currency ?? 'EUR',
color: dto.color,
icon: dto.icon,
includeInTotal: dto.includeInTotal ?? true,
order: (maxOrder?.maxOrder ?? 0) + 1,
})
.returning();
return account;
}
async update(userId: string, id: string, dto: UpdateAccountDto) {
// Verify ownership
await this.findOne(userId, id);
const [account] = await this.db
.update(accounts)
.set({
...(dto.name !== undefined && { name: dto.name }),
...(dto.type !== undefined && { type: dto.type }),
...(dto.balance !== undefined && { balance: dto.balance.toString() }),
...(dto.currency !== undefined && { currency: dto.currency }),
...(dto.color !== undefined && { color: dto.color }),
...(dto.icon !== undefined && { icon: dto.icon }),
...(dto.includeInTotal !== undefined && { includeInTotal: dto.includeInTotal }),
...(dto.isArchived !== undefined && { isArchived: dto.isArchived }),
...(dto.order !== undefined && { order: dto.order }),
updatedAt: new Date(),
})
.where(and(eq(accounts.id, id), eq(accounts.userId, userId)))
.returning();
return account;
}
async delete(userId: string, id: string) {
// Verify ownership
await this.findOne(userId, id);
await this.db.delete(accounts).where(and(eq(accounts.id, id), eq(accounts.userId, userId)));
return { success: true };
}
async archive(userId: string, id: string, archive = true) {
return this.update(userId, id, { isArchived: archive });
}
async getTotals(userId: string) {
const result = await this.db
.select({
currency: accounts.currency,
total: sql<string>`SUM(${accounts.balance})`,
count: sql<number>`COUNT(*)`,
})
.from(accounts)
.where(
and(
eq(accounts.userId, userId),
eq(accounts.isArchived, false),
eq(accounts.includeInTotal, true)
)
)
.groupBy(accounts.currency);
return result.map((r) => ({
currency: r.currency,
total: parseFloat(r.total ?? '0'),
count: Number(r.count),
}));
}
async reorder(userId: string, accountIds: string[]) {
// Update order for each account
await Promise.all(
accountIds.map((id, index) =>
this.db
.update(accounts)
.set({ order: index + 1 })
.where(and(eq(accounts.id, id), eq(accounts.userId, userId)))
)
);
return this.findAll(userId);
}
async updateBalance(userId: string, id: string, amount: number) {
const account = await this.findOne(userId, id);
const newBalance = parseFloat(account.balance) + amount;
const [updated] = await this.db
.update(accounts)
.set({
balance: newBalance.toString(),
updatedAt: new Date(),
})
.where(and(eq(accounts.id, id), eq(accounts.userId, userId)))
.returning();
return updated;
}
}

View file

@ -0,0 +1,45 @@
import {
IsString,
IsNotEmpty,
IsOptional,
IsBoolean,
IsNumber,
MaxLength,
IsIn,
} from 'class-validator';
const ACCOUNT_TYPES = ['checking', 'savings', 'credit_card', 'cash', 'investment', 'loan'] as const;
export class CreateAccountDto {
@IsString()
@IsNotEmpty()
@MaxLength(100)
name: string;
@IsString()
@IsIn(ACCOUNT_TYPES)
type: (typeof ACCOUNT_TYPES)[number];
@IsOptional()
@IsNumber()
balance?: number;
@IsOptional()
@IsString()
@MaxLength(3)
currency?: string;
@IsOptional()
@IsString()
@MaxLength(20)
color?: string;
@IsOptional()
@IsString()
@MaxLength(50)
icon?: string;
@IsOptional()
@IsBoolean()
includeInTotal?: boolean;
}

View file

@ -0,0 +1,2 @@
export * from './create-account.dto';
export * from './update-account.dto';

View file

@ -0,0 +1,46 @@
import { IsString, IsOptional, IsBoolean, IsNumber, MaxLength, IsIn } from 'class-validator';
const ACCOUNT_TYPES = ['checking', 'savings', 'credit_card', 'cash', 'investment', 'loan'] as const;
export class UpdateAccountDto {
@IsOptional()
@IsString()
@MaxLength(100)
name?: string;
@IsOptional()
@IsString()
@IsIn(ACCOUNT_TYPES)
type?: (typeof ACCOUNT_TYPES)[number];
@IsOptional()
@IsNumber()
balance?: number;
@IsOptional()
@IsString()
@MaxLength(3)
currency?: string;
@IsOptional()
@IsString()
@MaxLength(20)
color?: string;
@IsOptional()
@IsString()
@MaxLength(50)
icon?: string;
@IsOptional()
@IsBoolean()
includeInTotal?: boolean;
@IsOptional()
@IsBoolean()
isArchived?: boolean;
@IsOptional()
@IsNumber()
order?: number;
}

View file

@ -0,0 +1,34 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { DatabaseModule } from './db/database.module';
import { HealthModule } from './health/health.module';
import { AccountModule } from './account/account.module';
import { CategoryModule } from './category/category.module';
import { TransactionModule } from './transaction/transaction.module';
import { BudgetModule } from './budget/budget.module';
import { TransferModule } from './transfer/transfer.module';
import { ReportModule } from './report/report.module';
import { SettingsModule } from './settings/settings.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
ScheduleModule.forRoot(),
DatabaseModule,
HealthModule,
AccountModule,
CategoryModule,
TransactionModule,
BudgetModule,
TransferModule,
ReportModule,
SettingsModule,
ExchangeRateModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,69 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
ParseIntPipe,
} from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, type CurrentUserData } from '@manacore/shared-nestjs-auth';
import { BudgetService } from './budget.service';
import { CreateBudgetDto, UpdateBudgetDto } from './dto';
@Controller('budgets')
@UseGuards(JwtAuthGuard)
export class BudgetController {
constructor(private readonly budgetService: BudgetService) {}
@Get()
findAll(@CurrentUser() user: CurrentUserData) {
return this.budgetService.findAll(user.userId);
}
@Get('month/:year/:month')
findByMonth(
@CurrentUser() user: CurrentUserData,
@Param('year', ParseIntPipe) year: number,
@Param('month', ParseIntPipe) month: number
) {
return this.budgetService.findByMonth(user.userId, year, month);
}
@Get(':id')
findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
return this.budgetService.findOne(user.userId, id);
}
@Post()
create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateBudgetDto) {
return this.budgetService.create(user.userId, dto);
}
@Post('copy')
copyFromPreviousMonth(
@CurrentUser() user: CurrentUserData,
@Body('year') year: number,
@Body('month') month: number
) {
return this.budgetService.copyFromPreviousMonth(user.userId, year, month);
}
@Put(':id')
update(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateBudgetDto
) {
return this.budgetService.update(user.userId, id, dto);
}
@Delete(':id')
delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
return this.budgetService.delete(user.userId, id);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { BudgetController } from './budget.controller';
import { BudgetService } from './budget.service';
@Module({
controllers: [BudgetController],
providers: [BudgetService],
exports: [BudgetService],
})
export class BudgetModule {}

View file

@ -0,0 +1,220 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and, sql, gte, lte } from 'drizzle-orm';
import { DATABASE_CONNECTION, type Database } from '../db/connection';
import { budgets, transactions, categories } from '../db/schema';
import { CreateBudgetDto, UpdateBudgetDto } from './dto';
@Injectable()
export class BudgetService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findAll(userId: string) {
return this.db
.select({
budget: budgets,
category: {
id: categories.id,
name: categories.name,
color: categories.color,
icon: categories.icon,
},
})
.from(budgets)
.leftJoin(categories, eq(budgets.categoryId, categories.id))
.where(eq(budgets.userId, userId));
}
async findOne(userId: string, id: string) {
const [result] = await this.db
.select({
budget: budgets,
category: {
id: categories.id,
name: categories.name,
color: categories.color,
icon: categories.icon,
},
})
.from(budgets)
.leftJoin(categories, eq(budgets.categoryId, categories.id))
.where(and(eq(budgets.id, id), eq(budgets.userId, userId)));
if (!result) {
throw new NotFoundException(`Budget with ID ${id} not found`);
}
return {
...result.budget,
category: result.category,
};
}
async findByMonth(userId: string, year: number, month: number) {
// Get budgets for this month
const monthBudgets = await this.db
.select({
budget: budgets,
category: {
id: categories.id,
name: categories.name,
color: categories.color,
icon: categories.icon,
},
})
.from(budgets)
.leftJoin(categories, eq(budgets.categoryId, categories.id))
.where(and(eq(budgets.userId, userId), eq(budgets.month, month), eq(budgets.year, year)));
// Calculate spending for each budget
const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
const endDate = new Date(year, month, 0).toISOString().split('T')[0]; // Last day of month
const spending = await this.db
.select({
categoryId: transactions.categoryId,
total: sql<string>`SUM(${transactions.amount})`,
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
eq(transactions.type, 'expense'),
gte(transactions.date, startDate),
lte(transactions.date, endDate)
)
)
.groupBy(transactions.categoryId);
const spendingMap = new Map(spending.map((s) => [s.categoryId, parseFloat(s.total ?? '0')]));
// Calculate total spending for overall budget
const [totalSpending] = await this.db
.select({
total: sql<string>`SUM(${transactions.amount})`,
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
eq(transactions.type, 'expense'),
gte(transactions.date, startDate),
lte(transactions.date, endDate)
)
);
return monthBudgets.map((b) => ({
...b.budget,
category: b.category,
spent: b.budget.categoryId
? (spendingMap.get(b.budget.categoryId) ?? 0)
: parseFloat(totalSpending?.total ?? '0'),
remaining:
parseFloat(b.budget.amount) -
(b.budget.categoryId
? (spendingMap.get(b.budget.categoryId) ?? 0)
: parseFloat(totalSpending?.total ?? '0')),
percentage:
(b.budget.categoryId
? (spendingMap.get(b.budget.categoryId) ?? 0)
: parseFloat(totalSpending?.total ?? '0')) / parseFloat(b.budget.amount),
}));
}
async create(userId: string, dto: CreateBudgetDto) {
// Check if budget already exists for this category/month
const existing = await this.db
.select()
.from(budgets)
.where(
and(
eq(budgets.userId, userId),
eq(budgets.month, dto.month),
eq(budgets.year, dto.year),
dto.categoryId
? eq(budgets.categoryId, dto.categoryId)
: sql`${budgets.categoryId} IS NULL`
)
);
if (existing.length > 0) {
// Update existing budget
return this.update(userId, existing[0].id, {
amount: dto.amount,
alertThreshold: dto.alertThreshold,
rolloverEnabled: dto.rolloverEnabled,
});
}
const [budget] = await this.db
.insert(budgets)
.values({
userId,
categoryId: dto.categoryId,
month: dto.month,
year: dto.year,
amount: dto.amount.toString(),
alertThreshold: dto.alertThreshold?.toString() ?? '0.80',
rolloverEnabled: dto.rolloverEnabled ?? false,
})
.returning();
return budget;
}
async update(userId: string, id: string, dto: UpdateBudgetDto) {
await this.findOne(userId, id);
const [budget] = await this.db
.update(budgets)
.set({
...(dto.categoryId !== undefined && { categoryId: dto.categoryId }),
...(dto.amount !== undefined && { amount: dto.amount.toString() }),
...(dto.alertThreshold !== undefined && { alertThreshold: dto.alertThreshold.toString() }),
...(dto.rolloverEnabled !== undefined && { rolloverEnabled: dto.rolloverEnabled }),
updatedAt: new Date(),
})
.where(and(eq(budgets.id, id), eq(budgets.userId, userId)))
.returning();
return budget;
}
async delete(userId: string, id: string) {
await this.findOne(userId, id);
await this.db.delete(budgets).where(and(eq(budgets.id, id), eq(budgets.userId, userId)));
return { success: true };
}
async copyFromPreviousMonth(userId: string, year: number, month: number) {
// Calculate previous month
const prevMonth = month === 1 ? 12 : month - 1;
const prevYear = month === 1 ? year - 1 : year;
// Get previous month budgets
const prevBudgets = await this.db
.select()
.from(budgets)
.where(
and(eq(budgets.userId, userId), eq(budgets.month, prevMonth), eq(budgets.year, prevYear))
);
if (prevBudgets.length === 0) {
return { message: 'No budgets found in previous month', copied: 0 };
}
// Create budgets for current month
const newBudgets = prevBudgets.map((b) => ({
userId,
categoryId: b.categoryId,
month,
year,
amount: b.amount,
alertThreshold: b.alertThreshold,
rolloverEnabled: b.rolloverEnabled,
}));
await this.db.insert(budgets).values(newBudgets).onConflictDoNothing();
return { message: 'Budgets copied', copied: newBudgets.length };
}
}

View file

@ -0,0 +1,31 @@
import { IsString, IsOptional, IsBoolean, IsNumber, IsUUID, Min, Max } from 'class-validator';
export class CreateBudgetDto {
@IsOptional()
@IsUUID()
categoryId?: string;
@IsNumber()
@Min(1)
@Max(12)
month: number;
@IsNumber()
@Min(2000)
@Max(2100)
year: number;
@IsNumber()
@Min(0)
amount: number;
@IsOptional()
@IsNumber()
@Min(0)
@Max(1)
alertThreshold?: number;
@IsOptional()
@IsBoolean()
rolloverEnabled?: boolean;
}

View file

@ -0,0 +1,2 @@
export * from './create-budget.dto';
export * from './update-budget.dto';

View file

@ -0,0 +1,22 @@
import { IsOptional, IsBoolean, IsNumber, IsUUID, Min, Max } from 'class-validator';
export class UpdateBudgetDto {
@IsOptional()
@IsUUID()
categoryId?: string | null;
@IsOptional()
@IsNumber()
@Min(0)
amount?: number;
@IsOptional()
@IsNumber()
@Min(0)
@Max(1)
alertThreshold?: number;
@IsOptional()
@IsBoolean()
rolloverEnabled?: boolean;
}

View file

@ -0,0 +1,65 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, type CurrentUserData } from '@manacore/shared-nestjs-auth';
import { CategoryService } from './category.service';
import { CreateCategoryDto, UpdateCategoryDto } from './dto';
@Controller('categories')
@UseGuards(JwtAuthGuard)
export class CategoryController {
constructor(private readonly categoryService: CategoryService) {}
@Get()
findAll(@CurrentUser() user: CurrentUserData, @Query('type') type?: 'income' | 'expense') {
return this.categoryService.findAll(user.userId, type);
}
@Get('all')
findAllIncludingArchived(@CurrentUser() user: CurrentUserData) {
return this.categoryService.findAllIncludingArchived(user.userId);
}
@Get('tree')
getTree(@CurrentUser() user: CurrentUserData) {
return this.categoryService.getTree(user.userId);
}
@Get(':id')
findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
return this.categoryService.findOne(user.userId, id);
}
@Post()
create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateCategoryDto) {
return this.categoryService.create(user.userId, dto);
}
@Post('seed')
seed(@CurrentUser() user: CurrentUserData) {
return this.categoryService.seed(user.userId);
}
@Put(':id')
update(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateCategoryDto
) {
return this.categoryService.update(user.userId, id, dto);
}
@Delete(':id')
delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
return this.categoryService.delete(user.userId, id);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CategoryController } from './category.controller';
import { CategoryService } from './category.service';
@Module({
controllers: [CategoryController],
providers: [CategoryService],
exports: [CategoryService],
})
export class CategoryModule {}

View file

@ -0,0 +1,173 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and, asc, isNull } from 'drizzle-orm';
import { DATABASE_CONNECTION, type Database } from '../db/connection';
import { categories } from '../db/schema';
import { CreateCategoryDto, UpdateCategoryDto } from './dto';
// Default categories to seed
const DEFAULT_CATEGORIES = {
expense: [
{ name: 'Lebensmittel', color: '#22c55e', icon: 'shopping-cart' },
{ name: 'Restaurant', color: '#f97316', icon: 'utensils' },
{ name: 'Transport', color: '#3b82f6', icon: 'car' },
{ name: 'Wohnen', color: '#a855f7', icon: 'home' },
{ name: 'Versicherungen', color: '#6b7280', icon: 'shield' },
{ name: 'Gesundheit', color: '#ef4444', icon: 'heart' },
{ name: 'Unterhaltung', color: '#ec4899', icon: 'film' },
{ name: 'Shopping', color: '#eab308', icon: 'shopping-bag' },
{ name: 'Bildung', color: '#6366f1', icon: 'book' },
{ name: 'Reisen', color: '#06b6d4', icon: 'plane' },
{ name: 'Abonnements', color: '#8b5cf6', icon: 'credit-card' },
{ name: 'Sonstiges', color: '#9ca3af', icon: 'more-horizontal' },
],
income: [
{ name: 'Gehalt', color: '#22c55e', icon: 'briefcase' },
{ name: 'Nebeneinkommen', color: '#3b82f6', icon: 'trending-up' },
{ name: 'Investitionen', color: '#a855f7', icon: 'bar-chart' },
{ name: 'Geschenke', color: '#ec4899', icon: 'gift' },
{ name: 'Sonstiges', color: '#9ca3af', icon: 'more-horizontal' },
],
};
@Injectable()
export class CategoryService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findAll(userId: string, type?: 'income' | 'expense') {
const conditions = [eq(categories.userId, userId), eq(categories.isArchived, false)];
if (type) {
conditions.push(eq(categories.type, type));
}
return this.db
.select()
.from(categories)
.where(and(...conditions))
.orderBy(asc(categories.name));
}
async findAllIncludingArchived(userId: string) {
return this.db
.select()
.from(categories)
.where(eq(categories.userId, userId))
.orderBy(asc(categories.name));
}
async findOne(userId: string, id: string) {
const [category] = await this.db
.select()
.from(categories)
.where(and(eq(categories.id, id), eq(categories.userId, userId)));
if (!category) {
throw new NotFoundException(`Category with ID ${id} not found`);
}
return category;
}
async create(userId: string, dto: CreateCategoryDto) {
const [category] = await this.db
.insert(categories)
.values({
userId,
name: dto.name,
type: dto.type,
parentId: dto.parentId,
color: dto.color,
icon: dto.icon,
isSystem: false,
})
.returning();
return category;
}
async update(userId: string, id: string, dto: UpdateCategoryDto) {
// Verify ownership
await this.findOne(userId, id);
const [category] = await this.db
.update(categories)
.set({
...(dto.name !== undefined && { name: dto.name }),
...(dto.type !== undefined && { type: dto.type }),
...(dto.parentId !== undefined && { parentId: dto.parentId }),
...(dto.color !== undefined && { color: dto.color }),
...(dto.icon !== undefined && { icon: dto.icon }),
...(dto.isArchived !== undefined && { isArchived: dto.isArchived }),
updatedAt: new Date(),
})
.where(and(eq(categories.id, id), eq(categories.userId, userId)))
.returning();
return category;
}
async delete(userId: string, id: string) {
// Verify ownership
const category = await this.findOne(userId, id);
// Don't allow deleting system categories
if (category.isSystem) {
throw new Error('Cannot delete system categories');
}
await this.db
.delete(categories)
.where(and(eq(categories.id, id), eq(categories.userId, userId)));
return { success: true };
}
async seed(userId: string) {
// Check if user already has categories
const existing = await this.db
.select()
.from(categories)
.where(eq(categories.userId, userId))
.limit(1);
if (existing.length > 0) {
return { message: 'Categories already exist', seeded: false };
}
const categoriesToInsert = [
...DEFAULT_CATEGORIES.expense.map((c) => ({
userId,
name: c.name,
type: 'expense' as const,
color: c.color,
icon: c.icon,
isSystem: true,
})),
...DEFAULT_CATEGORIES.income.map((c) => ({
userId,
name: c.name,
type: 'income' as const,
color: c.color,
icon: c.icon,
isSystem: true,
})),
];
await this.db.insert(categories).values(categoriesToInsert);
return { message: 'Categories seeded', seeded: true, count: categoriesToInsert.length };
}
async getTree(userId: string) {
const allCategories = await this.findAll(userId);
// Build tree structure
const rootCategories = allCategories.filter((c) => !c.parentId);
const childCategories = allCategories.filter((c) => c.parentId);
return rootCategories.map((parent) => ({
...parent,
children: childCategories.filter((c) => c.parentId === parent.id),
}));
}
}

View file

@ -0,0 +1,36 @@
import {
IsString,
IsNotEmpty,
IsOptional,
IsBoolean,
IsUUID,
MaxLength,
IsIn,
} from 'class-validator';
const CATEGORY_TYPES = ['income', 'expense'] as const;
export class CreateCategoryDto {
@IsString()
@IsNotEmpty()
@MaxLength(100)
name: string;
@IsString()
@IsIn(CATEGORY_TYPES)
type: (typeof CATEGORY_TYPES)[number];
@IsOptional()
@IsUUID()
parentId?: string;
@IsOptional()
@IsString()
@MaxLength(20)
color?: string;
@IsOptional()
@IsString()
@MaxLength(50)
icon?: string;
}

View file

@ -0,0 +1,2 @@
export * from './create-category.dto';
export * from './update-category.dto';

View file

@ -0,0 +1,33 @@
import { IsString, IsOptional, IsBoolean, IsUUID, MaxLength, IsIn } from 'class-validator';
const CATEGORY_TYPES = ['income', 'expense'] as const;
export class UpdateCategoryDto {
@IsOptional()
@IsString()
@MaxLength(100)
name?: string;
@IsOptional()
@IsString()
@IsIn(CATEGORY_TYPES)
type?: (typeof CATEGORY_TYPES)[number];
@IsOptional()
@IsUUID()
parentId?: string | null;
@IsOptional()
@IsString()
@MaxLength(20)
color?: string;
@IsOptional()
@IsString()
@MaxLength(50)
icon?: string;
@IsOptional()
@IsBoolean()
isArchived?: boolean;
}

View file

@ -0,0 +1,29 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
let connection: ReturnType<typeof postgres> | null = null;
let db: ReturnType<typeof drizzle> | null = null;
export function getDb(databaseUrl: string) {
if (!db) {
connection = postgres(databaseUrl, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
db = drizzle(connection, { schema });
}
return db;
}
export async function closeConnection() {
if (connection) {
await connection.end();
connection = null;
db = null;
}
}
export type Database = ReturnType<typeof getDb>;
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';

View file

@ -0,0 +1,26 @@
import { Global, Module, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getDb, closeConnection, DATABASE_CONNECTION, type Database } from './connection';
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
return getDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule implements OnModuleDestroy {
async onModuleDestroy() {
await closeConnection();
}
}

View file

@ -0,0 +1,63 @@
import {
pgTable,
uuid,
timestamp,
varchar,
text,
boolean,
decimal,
integer,
index,
} from 'drizzle-orm/pg-core';
export type AccountType =
| 'checking'
| 'savings'
| 'credit_card'
| 'cash'
| 'investment'
| 'loan'
| 'other';
export const accounts = pgTable(
'accounts',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
// Basic info
name: varchar('name', { length: 100 }).notNull(),
type: varchar('type', { length: 20 }).notNull().$type<AccountType>(),
// Balance
balance: decimal('balance', { precision: 15, scale: 2 }).default('0').notNull(),
currency: varchar('currency', { length: 3 }).default('EUR').notNull(),
// Display
color: varchar('color', { length: 7 }).default('#3B82F6'),
icon: varchar('icon', { length: 50 }).default('wallet'),
// Status
isArchived: boolean('is_archived').default(false).notNull(),
includeInTotal: boolean('include_in_total').default(true).notNull(),
// Ordering
order: integer('order').default(0).notNull(),
// Metadata
description: text('description'),
institutionName: varchar('institution_name', { length: 100 }),
accountNumber: varchar('account_number', { length: 50 }), // Last 4 digits only
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
userIdx: index('accounts_user_idx').on(table.userId),
typeIdx: index('accounts_type_idx').on(table.type),
orderIdx: index('accounts_order_idx').on(table.order),
})
);
export type Account = typeof accounts.$inferSelect;
export type NewAccount = typeof accounts.$inferInsert;

View file

@ -0,0 +1,51 @@
import {
pgTable,
uuid,
timestamp,
varchar,
decimal,
integer,
boolean,
index,
} from 'drizzle-orm/pg-core';
import { categories } from './categories.schema';
export const budgets = pgTable(
'budgets',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
// Category (null = overall budget)
categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'cascade' }),
// Period
month: integer('month').notNull(), // 1-12
year: integer('year').notNull(),
// Amount
amount: decimal('amount', { precision: 15, scale: 2 }).notNull(),
currency: varchar('currency', { length: 3 }).default('EUR').notNull(),
// Alert settings
alertThreshold: decimal('alert_threshold', { precision: 5, scale: 2 })
.default('0.80')
.notNull(), // 80%
alertEnabled: boolean('alert_enabled').default(true).notNull(),
// Rollover (unused budget carries to next month)
rolloverEnabled: boolean('rollover_enabled').default(false).notNull(),
rolloverAmount: decimal('rollover_amount', { precision: 15, scale: 2 }).default('0').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
userIdx: index('budgets_user_idx').on(table.userId),
categoryIdx: index('budgets_category_idx').on(table.categoryId),
periodIdx: index('budgets_period_idx').on(table.year, table.month),
})
);
export type Budget = typeof budgets.$inferSelect;
export type NewBudget = typeof budgets.$inferInsert;

View file

@ -0,0 +1,41 @@
import { pgTable, uuid, timestamp, varchar, boolean, integer, index } from 'drizzle-orm/pg-core';
export type CategoryType = 'income' | 'expense';
export const categories = pgTable(
'categories',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
// Basic info
name: varchar('name', { length: 100 }).notNull(),
type: varchar('type', { length: 10 }).notNull().$type<CategoryType>(),
// Hierarchy (for subcategories)
parentId: uuid('parent_id'),
// Display
color: varchar('color', { length: 7 }).default('#6B7280'),
icon: varchar('icon', { length: 50 }).default('tag'),
// Ordering
order: integer('order').default(0).notNull(),
// Status
isSystem: boolean('is_system').default(false).notNull(), // For default categories
isArchived: boolean('is_archived').default(false).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
userIdx: index('categories_user_idx').on(table.userId),
typeIdx: index('categories_type_idx').on(table.type),
parentIdx: index('categories_parent_idx').on(table.parentId),
orderIdx: index('categories_order_idx').on(table.order),
})
);
export type Category = typeof categories.$inferSelect;
export type NewCategory = typeof categories.$inferInsert;

View file

@ -0,0 +1,42 @@
import { pgTable, uuid, timestamp, varchar, jsonb, index } from 'drizzle-orm/pg-core';
import { accounts } from './accounts.schema';
export type ConnectionStatus = 'active' | 'disconnected' | 'error';
export const connectedAccounts = pgTable(
'connected_accounts',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
// Link to local account
accountId: uuid('account_id')
.references(() => accounts.id, { onDelete: 'cascade' })
.notNull(),
// Provider info
provider: varchar('provider', { length: 50 }).notNull(), // plaid, gocardless, etc.
externalId: varchar('external_id', { length: 255 }).notNull(),
// Status
status: varchar('status', { length: 20 }).default('active').notNull().$type<ConnectionStatus>(),
// Sync info
lastSyncAt: timestamp('last_sync_at', { withTimezone: true }),
// Provider-specific metadata
metadata: jsonb('metadata').$type<Record<string, unknown>>(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
userIdx: index('connected_accounts_user_idx').on(table.userId),
accountIdx: index('connected_accounts_account_idx').on(table.accountId),
providerIdx: index('connected_accounts_provider_idx').on(table.provider),
externalIdx: index('connected_accounts_external_idx').on(table.externalId),
})
);
export type ConnectedAccount = typeof connectedAccounts.$inferSelect;
export type NewConnectedAccount = typeof connectedAccounts.$inferInsert;

View file

@ -0,0 +1,28 @@
import { pgTable, uuid, varchar, decimal, date, index } from 'drizzle-orm/pg-core';
export const exchangeRates = pgTable(
'exchange_rates',
{
id: uuid('id').primaryKey().defaultRandom(),
// Currency pair
fromCurrency: varchar('from_currency', { length: 3 }).notNull(),
toCurrency: varchar('to_currency', { length: 3 }).notNull(),
// Rate
rate: decimal('rate', { precision: 15, scale: 6 }).notNull(),
// Date
date: date('date').notNull(),
},
(table) => ({
currencyPairIdx: index('exchange_rates_currency_pair_idx').on(
table.fromCurrency,
table.toCurrency
),
dateIdx: index('exchange_rates_date_idx').on(table.date),
})
);
export type ExchangeRate = typeof exchangeRates.$inferSelect;
export type NewExchangeRate = typeof exchangeRates.$inferInsert;

View file

@ -0,0 +1,8 @@
export * from './accounts.schema';
export * from './categories.schema';
export * from './transactions.schema';
export * from './budgets.schema';
export * from './transfers.schema';
export * from './exchange-rates.schema';
export * from './user-settings.schema';
export * from './connected-accounts.schema';

View file

@ -0,0 +1,83 @@
import {
pgTable,
uuid,
timestamp,
varchar,
text,
boolean,
decimal,
date,
jsonb,
index,
} from 'drizzle-orm/pg-core';
import { accounts } from './accounts.schema';
import { categories } from './categories.schema';
export type TransactionType = 'income' | 'expense';
export interface RecurrenceRule {
frequency: 'daily' | 'weekly' | 'biweekly' | 'monthly' | 'yearly';
interval: number;
endDate?: string;
dayOfMonth?: number;
dayOfWeek?: number;
}
export const transactions = pgTable(
'transactions',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
// Relations
accountId: uuid('account_id')
.references(() => accounts.id, { onDelete: 'cascade' })
.notNull(),
categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }),
// Transaction details
type: varchar('type', { length: 10 }).notNull().$type<TransactionType>(),
amount: decimal('amount', { precision: 15, scale: 2 }).notNull(),
currency: varchar('currency', { length: 3 }).default('EUR').notNull(),
// Date
date: date('date').notNull(),
// Description
description: text('description'),
notes: text('notes'),
// Payee/Payer
payee: varchar('payee', { length: 200 }),
// Recurrence
isRecurring: boolean('is_recurring').default(false).notNull(),
recurrenceRule: jsonb('recurrence_rule').$type<RecurrenceRule>(),
parentTransactionId: uuid('parent_transaction_id'), // For recurring instances
// Status
isPending: boolean('is_pending').default(false).notNull(),
isReconciled: boolean('is_reconciled').default(false).notNull(),
// Tags (stored as array)
tags: jsonb('tags').$type<string[]>().default([]),
// Attachments (receipt images, etc.)
attachments: jsonb('attachments').$type<string[]>().default([]),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
userIdx: index('transactions_user_idx').on(table.userId),
accountIdx: index('transactions_account_idx').on(table.accountId),
categoryIdx: index('transactions_category_idx').on(table.categoryId),
dateIdx: index('transactions_date_idx').on(table.date),
typeIdx: index('transactions_type_idx').on(table.type),
recurringIdx: index('transactions_recurring_idx').on(table.isRecurring),
parentIdx: index('transactions_parent_idx').on(table.parentTransactionId),
})
);
export type Transaction = typeof transactions.$inferSelect;
export type NewTransaction = typeof transactions.$inferInsert;

View file

@ -0,0 +1,39 @@
import { pgTable, uuid, timestamp, text, decimal, date, index } from 'drizzle-orm/pg-core';
import { accounts } from './accounts.schema';
export const transfers = pgTable(
'transfers',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
// Accounts
fromAccountId: uuid('from_account_id')
.references(() => accounts.id, { onDelete: 'cascade' })
.notNull(),
toAccountId: uuid('to_account_id')
.references(() => accounts.id, { onDelete: 'cascade' })
.notNull(),
// Amount
amount: decimal('amount', { precision: 15, scale: 2 }).notNull(),
// Date
date: date('date').notNull(),
// Description
description: text('description'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
userIdx: index('transfers_user_idx').on(table.userId),
fromAccountIdx: index('transfers_from_account_idx').on(table.fromAccountId),
toAccountIdx: index('transfers_to_account_idx').on(table.toAccountId),
dateIdx: index('transfers_date_idx').on(table.date),
})
);
export type Transfer = typeof transfers.$inferSelect;
export type NewTransfer = typeof transfers.$inferInsert;

View file

@ -0,0 +1,30 @@
import { pgTable, uuid, timestamp, varchar, integer, index } from 'drizzle-orm/pg-core';
export const userSettings = pgTable(
'user_settings',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull().unique(),
// Currency
defaultCurrency: varchar('default_currency', { length: 3 }).default('EUR').notNull(),
// Locale
locale: varchar('locale', { length: 10 }).default('de-DE').notNull(),
// Date format
dateFormat: varchar('date_format', { length: 20 }).default('dd.MM.yyyy').notNull(),
// Week start (0 = Sunday, 1 = Monday)
weekStartsOn: integer('week_starts_on').default(1).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
userIdx: index('user_settings_user_idx').on(table.userId),
})
);
export type UserSettings = typeof userSettings.$inferSelect;
export type NewUserSettings = typeof userSettings.$inferInsert;

View file

@ -0,0 +1,38 @@
import { Controller, Get, Post, Body, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '@manacore/shared-nestjs-auth';
import { ExchangeRateService } from './exchange-rate.service';
@Controller('exchange-rates')
@UseGuards(JwtAuthGuard)
export class ExchangeRateController {
constructor(private readonly exchangeRateService: ExchangeRateService) {}
@Get()
getAllRates(@Query('base') baseCurrency?: string) {
return this.exchangeRateService.getAllRates(baseCurrency);
}
@Get('rate')
getRate(@Query('from') fromCurrency: string, @Query('to') toCurrency: string) {
return this.exchangeRateService.getRate(fromCurrency, toCurrency);
}
@Get('convert')
convert(
@Query('amount') amount: number,
@Query('from') fromCurrency: string,
@Query('to') toCurrency: string
) {
return this.exchangeRateService.convert(amount, fromCurrency, toCurrency);
}
@Post('seed')
seedRates() {
return this.exchangeRateService.seedRates();
}
@Post('fetch')
fetchRates() {
return this.exchangeRateService.fetchRates();
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ExchangeRateController } from './exchange-rate.controller';
import { ExchangeRateService } from './exchange-rate.service';
@Module({
controllers: [ExchangeRateController],
providers: [ExchangeRateService],
exports: [ExchangeRateService],
})
export class ExchangeRateModule {}

View file

@ -0,0 +1,190 @@
import { Injectable, Inject } from '@nestjs/common';
import { eq, and, desc } from 'drizzle-orm';
import { Cron, CronExpression } from '@nestjs/schedule';
import { DATABASE_CONNECTION, type Database } from '../db/connection';
import { exchangeRates } from '../db/schema';
// Common currencies
const SUPPORTED_CURRENCIES = [
'EUR',
'USD',
'GBP',
'CHF',
'JPY',
'CAD',
'AUD',
'CNY',
'INR',
'BRL',
'MXN',
'PLN',
'SEK',
];
@Injectable()
export class ExchangeRateService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async getRate(fromCurrency: string, toCurrency: string): Promise<number> {
if (fromCurrency === toCurrency) {
return 1;
}
// Try direct rate
const [directRate] = await this.db
.select()
.from(exchangeRates)
.where(
and(eq(exchangeRates.fromCurrency, fromCurrency), eq(exchangeRates.toCurrency, toCurrency))
)
.orderBy(desc(exchangeRates.date))
.limit(1);
if (directRate) {
return parseFloat(directRate.rate);
}
// Try inverse rate
const [inverseRate] = await this.db
.select()
.from(exchangeRates)
.where(
and(eq(exchangeRates.fromCurrency, toCurrency), eq(exchangeRates.toCurrency, fromCurrency))
)
.orderBy(desc(exchangeRates.date))
.limit(1);
if (inverseRate) {
return 1 / parseFloat(inverseRate.rate);
}
// Try through EUR as base
const [toEur] = await this.db
.select()
.from(exchangeRates)
.where(and(eq(exchangeRates.fromCurrency, fromCurrency), eq(exchangeRates.toCurrency, 'EUR')))
.orderBy(desc(exchangeRates.date))
.limit(1);
const [fromEur] = await this.db
.select()
.from(exchangeRates)
.where(and(eq(exchangeRates.fromCurrency, 'EUR'), eq(exchangeRates.toCurrency, toCurrency)))
.orderBy(desc(exchangeRates.date))
.limit(1);
if (toEur && fromEur) {
return parseFloat(toEur.rate) * parseFloat(fromEur.rate);
}
// Default fallback
return 1;
}
async convert(amount: number, fromCurrency: string, toCurrency: string): Promise<number> {
const rate = await this.getRate(fromCurrency, toCurrency);
return amount * rate;
}
async getAllRates(baseCurrency = 'EUR') {
const rates = await this.db
.select()
.from(exchangeRates)
.where(eq(exchangeRates.fromCurrency, baseCurrency))
.orderBy(desc(exchangeRates.date));
// Get latest rate for each currency pair
const latestRates = new Map<string, (typeof rates)[0]>();
rates.forEach((rate) => {
if (!latestRates.has(rate.toCurrency)) {
latestRates.set(rate.toCurrency, rate);
}
});
return Array.from(latestRates.values()).map((r) => ({
fromCurrency: r.fromCurrency,
toCurrency: r.toCurrency,
rate: parseFloat(r.rate),
date: r.date,
}));
}
async setRate(fromCurrency: string, toCurrency: string, rate: number) {
const today = new Date().toISOString().split('T')[0];
// Upsert rate
const [existing] = await this.db
.select()
.from(exchangeRates)
.where(
and(
eq(exchangeRates.fromCurrency, fromCurrency),
eq(exchangeRates.toCurrency, toCurrency),
eq(exchangeRates.date, today)
)
);
if (existing) {
const [updated] = await this.db
.update(exchangeRates)
.set({ rate: rate.toString() })
.where(eq(exchangeRates.id, existing.id))
.returning();
return updated;
}
const [created] = await this.db
.insert(exchangeRates)
.values({
fromCurrency,
toCurrency,
rate: rate.toString(),
date: today,
})
.returning();
return created;
}
// Fetch rates from ECB (free, no API key required)
@Cron(CronExpression.EVERY_DAY_AT_6AM)
async fetchRates() {
try {
const response = await fetch('https://api.frankfurter.app/latest?from=EUR');
const data = await response.json();
if (data.rates) {
const today = data.date;
const rates = Object.entries(data.rates) as [string, number][];
for (const [currency, rate] of rates) {
await this.db
.insert(exchangeRates)
.values({
fromCurrency: 'EUR',
toCurrency: currency,
rate: rate.toString(),
date: today,
})
.onConflictDoNothing();
}
console.log(`Fetched ${rates.length} exchange rates for ${today}`);
}
} catch (error) {
console.error('Failed to fetch exchange rates:', error);
}
}
async seedRates() {
// Seed some default rates if none exist
const existing = await this.db.select().from(exchangeRates).limit(1);
if (existing.length > 0) {
return { message: 'Rates already exist', seeded: false };
}
await this.fetchRates();
return { message: 'Rates seeded', seeded: true };
}
}

View file

@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
service: 'finance-backend',
};
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View file

@ -0,0 +1,45 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
// CORS configuration
const corsOrigins = configService.get<string>('CORS_ORIGINS')?.split(',') || [
'http://localhost:5173',
'http://localhost:5189',
'http://localhost:8081',
];
app.enableCors({
origin: corsOrigins,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
credentials: true,
});
// Global validation pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
})
);
// API prefix
app.setGlobalPrefix('api/v1');
const port = configService.get<number>('PORT') || 3019;
await app.listen(port);
console.log(`Finance Backend running on http://localhost:${port}`);
console.log(`Health check: http://localhost:${port}/api/v1/health`);
}
bootstrap();

View file

@ -0,0 +1,50 @@
import { Controller, Get, Query, UseGuards, ParseIntPipe, DefaultValuePipe } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, type CurrentUserData } from '@manacore/shared-nestjs-auth';
import { ReportService } from './report.service';
@Controller('reports')
@UseGuards(JwtAuthGuard)
export class ReportController {
constructor(private readonly reportService: ReportService) {}
@Get('dashboard')
getDashboard(@CurrentUser() user: CurrentUserData) {
return this.reportService.getDashboard(user.userId);
}
@Get('monthly-summary')
getMonthlySummary(
@CurrentUser() user: CurrentUserData,
@Query('year', new DefaultValuePipe(new Date().getFullYear()), ParseIntPipe) year: number,
@Query('month', new DefaultValuePipe(new Date().getMonth() + 1), ParseIntPipe) month: number
) {
return this.reportService.getMonthlySummary(user.userId, year, month);
}
@Get('category-breakdown')
getCategoryBreakdown(
@CurrentUser() user: CurrentUserData,
@Query('startDate') startDate: string,
@Query('endDate') endDate: string,
@Query('type') type?: 'income' | 'expense'
) {
return this.reportService.getCategoryBreakdown(user.userId, startDate, endDate, type);
}
@Get('trends')
getTrends(
@CurrentUser() user: CurrentUserData,
@Query('months', new DefaultValuePipe(6), ParseIntPipe) months: number
) {
return this.reportService.getTrends(user.userId, months);
}
@Get('cash-flow')
getCashFlow(
@CurrentUser() user: CurrentUserData,
@Query('startDate') startDate: string,
@Query('endDate') endDate: string
) {
return this.reportService.getCashFlow(user.userId, startDate, endDate);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ReportController } from './report.controller';
import { ReportService } from './report.service';
@Module({
controllers: [ReportController],
providers: [ReportService],
exports: [ReportService],
})
export class ReportModule {}

View file

@ -0,0 +1,396 @@
import { Injectable, Inject } from '@nestjs/common';
import { eq, and, sql, gte, lte, desc } from 'drizzle-orm';
import { DATABASE_CONNECTION, type Database } from '../db/connection';
import { transactions, accounts, categories, budgets } from '../db/schema';
@Injectable()
export class ReportService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async getDashboard(userId: string) {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
// Current month range
const startOfMonth = `${year}-${String(month).padStart(2, '0')}-01`;
const endOfMonth = new Date(year, month, 0).toISOString().split('T')[0];
// Account totals
const accountTotals = await this.db
.select({
currency: accounts.currency,
total: sql<string>`SUM(${accounts.balance})`,
})
.from(accounts)
.where(
and(
eq(accounts.userId, userId),
eq(accounts.isArchived, false),
eq(accounts.includeInTotal, true)
)
)
.groupBy(accounts.currency);
// Current month income/expense
const monthlyTotals = await this.db
.select({
type: transactions.type,
total: sql<string>`SUM(${transactions.amount})`,
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
gte(transactions.date, startOfMonth),
lte(transactions.date, endOfMonth)
)
)
.groupBy(transactions.type);
const income = monthlyTotals.find((t) => t.type === 'income');
const expense = monthlyTotals.find((t) => t.type === 'expense');
// Budget progress
const budgetProgress = await this.db
.select({
budget: budgets,
category: {
id: categories.id,
name: categories.name,
color: categories.color,
},
})
.from(budgets)
.leftJoin(categories, eq(budgets.categoryId, categories.id))
.where(and(eq(budgets.userId, userId), eq(budgets.month, month), eq(budgets.year, year)));
// Get spending per category
const categorySpending = await this.db
.select({
categoryId: transactions.categoryId,
total: sql<string>`SUM(${transactions.amount})`,
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
eq(transactions.type, 'expense'),
gte(transactions.date, startOfMonth),
lte(transactions.date, endOfMonth)
)
)
.groupBy(transactions.categoryId);
const spendingMap = new Map(
categorySpending.map((s) => [s.categoryId, parseFloat(s.total ?? '0')])
);
// Recent transactions
const recentTransactions = await this.db
.select({
transaction: transactions,
category: {
id: categories.id,
name: categories.name,
color: categories.color,
icon: categories.icon,
},
account: {
id: accounts.id,
name: accounts.name,
color: accounts.color,
},
})
.from(transactions)
.leftJoin(categories, eq(transactions.categoryId, categories.id))
.leftJoin(accounts, eq(transactions.accountId, accounts.id))
.where(eq(transactions.userId, userId))
.orderBy(desc(transactions.date), desc(transactions.createdAt))
.limit(5);
return {
totals: accountTotals.map((t) => ({
currency: t.currency,
amount: parseFloat(t.total ?? '0'),
})),
currentMonth: {
year,
month,
income: parseFloat(income?.total ?? '0'),
expense: parseFloat(expense?.total ?? '0'),
net: parseFloat(income?.total ?? '0') - parseFloat(expense?.total ?? '0'),
},
budgets: budgetProgress.map((b) => ({
id: b.budget.id,
category: b.category,
amount: parseFloat(b.budget.amount),
spent: b.budget.categoryId
? (spendingMap.get(b.budget.categoryId) ?? 0)
: parseFloat(expense?.total ?? '0'),
percentage:
(b.budget.categoryId
? (spendingMap.get(b.budget.categoryId) ?? 0)
: parseFloat(expense?.total ?? '0')) / parseFloat(b.budget.amount),
})),
recentTransactions: recentTransactions.map((r) => ({
...r.transaction,
category: r.category,
account: r.account,
})),
};
}
async getMonthlySummary(userId: string, year: number, month: number) {
const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
const endDate = new Date(year, month, 0).toISOString().split('T')[0];
// Totals by type
const totals = await this.db
.select({
type: transactions.type,
total: sql<string>`SUM(${transactions.amount})`,
count: sql<number>`COUNT(*)`,
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
gte(transactions.date, startDate),
lte(transactions.date, endDate)
)
)
.groupBy(transactions.type);
// Daily breakdown
const dailyBreakdown = await this.db
.select({
date: transactions.date,
type: transactions.type,
total: sql<string>`SUM(${transactions.amount})`,
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
gte(transactions.date, startDate),
lte(transactions.date, endDate)
)
)
.groupBy(transactions.date, transactions.type)
.orderBy(transactions.date);
// Top expenses
const topExpenses = await this.db
.select({
transaction: transactions,
category: {
id: categories.id,
name: categories.name,
color: categories.color,
},
})
.from(transactions)
.leftJoin(categories, eq(transactions.categoryId, categories.id))
.where(
and(
eq(transactions.userId, userId),
eq(transactions.type, 'expense'),
gte(transactions.date, startDate),
lte(transactions.date, endDate)
)
)
.orderBy(desc(transactions.amount))
.limit(10);
const income = totals.find((t) => t.type === 'income');
const expense = totals.find((t) => t.type === 'expense');
return {
year,
month,
income: parseFloat(income?.total ?? '0'),
expense: parseFloat(expense?.total ?? '0'),
net: parseFloat(income?.total ?? '0') - parseFloat(expense?.total ?? '0'),
incomeCount: Number(income?.count ?? 0),
expenseCount: Number(expense?.count ?? 0),
dailyBreakdown: dailyBreakdown.map((d) => ({
date: d.date,
type: d.type,
amount: parseFloat(d.total ?? '0'),
})),
topExpenses: topExpenses.map((e) => ({
...e.transaction,
category: e.category,
})),
};
}
async getCategoryBreakdown(
userId: string,
startDate: string,
endDate: string,
type: 'income' | 'expense' = 'expense'
) {
const breakdown = await this.db
.select({
categoryId: transactions.categoryId,
categoryName: categories.name,
categoryColor: categories.color,
categoryIcon: categories.icon,
total: sql<string>`SUM(${transactions.amount})`,
count: sql<number>`COUNT(*)`,
})
.from(transactions)
.leftJoin(categories, eq(transactions.categoryId, categories.id))
.where(
and(
eq(transactions.userId, userId),
eq(transactions.type, type),
gte(transactions.date, startDate),
lte(transactions.date, endDate)
)
)
.groupBy(transactions.categoryId, categories.name, categories.color, categories.icon)
.orderBy(desc(sql`SUM(${transactions.amount})`));
const total = breakdown.reduce((sum, b) => sum + parseFloat(b.total ?? '0'), 0);
return {
startDate,
endDate,
type,
total,
categories: breakdown.map((b) => ({
categoryId: b.categoryId,
name: b.categoryName ?? 'Uncategorized',
color: b.categoryColor,
icon: b.categoryIcon,
amount: parseFloat(b.total ?? '0'),
count: Number(b.count),
percentage: total > 0 ? parseFloat(b.total ?? '0') / total : 0,
})),
};
}
async getTrends(userId: string, months = 6) {
const trends = [];
const now = new Date();
for (let i = 0; i < months; i++) {
const date = new Date(now.getFullYear(), now.getMonth() - i, 1);
const year = date.getFullYear();
const month = date.getMonth() + 1;
const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
const endDate = new Date(year, month, 0).toISOString().split('T')[0];
const totals = await this.db
.select({
type: transactions.type,
total: sql<string>`SUM(${transactions.amount})`,
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
gte(transactions.date, startDate),
lte(transactions.date, endDate)
)
)
.groupBy(transactions.type);
const income = totals.find((t) => t.type === 'income');
const expense = totals.find((t) => t.type === 'expense');
trends.unshift({
year,
month,
income: parseFloat(income?.total ?? '0'),
expense: parseFloat(expense?.total ?? '0'),
net: parseFloat(income?.total ?? '0') - parseFloat(expense?.total ?? '0'),
});
}
return {
months,
data: trends,
averages: {
income: trends.reduce((sum, t) => sum + t.income, 0) / months,
expense: trends.reduce((sum, t) => sum + t.expense, 0) / months,
net: trends.reduce((sum, t) => sum + t.net, 0) / months,
},
};
}
async getCashFlow(userId: string, startDate: string, endDate: string) {
// Get starting balance
const startBalance = await this.db
.select({
total: sql<string>`SUM(${accounts.balance})`,
})
.from(accounts)
.where(
and(
eq(accounts.userId, userId),
eq(accounts.isArchived, false),
eq(accounts.includeInTotal, true)
)
);
// Get daily transactions
const dailyFlow = await this.db
.select({
date: transactions.date,
type: transactions.type,
total: sql<string>`SUM(${transactions.amount})`,
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
gte(transactions.date, startDate),
lte(transactions.date, endDate)
)
)
.groupBy(transactions.date, transactions.type)
.orderBy(transactions.date);
// Build cumulative cash flow
let runningTotal = parseFloat(startBalance[0]?.total ?? '0');
const cashFlow: { date: string; balance: number; income: number; expense: number }[] = [];
// Group by date
const byDate = new Map<string, { income: number; expense: number }>();
dailyFlow.forEach((d) => {
if (!byDate.has(d.date)) {
byDate.set(d.date, { income: 0, expense: 0 });
}
const entry = byDate.get(d.date)!;
if (d.type === 'income') {
entry.income = parseFloat(d.total ?? '0');
} else {
entry.expense = parseFloat(d.total ?? '0');
}
});
// Convert to array with running balance
byDate.forEach((value, date) => {
runningTotal += value.income - value.expense;
cashFlow.push({
date,
balance: runningTotal,
income: value.income,
expense: value.expense,
});
});
return {
startDate,
endDate,
startingBalance: parseFloat(startBalance[0]?.total ?? '0'),
endingBalance: runningTotal,
data: cashFlow,
};
}
}

View file

@ -0,0 +1 @@
export * from './update-settings.dto';

View file

@ -0,0 +1,24 @@
import { IsString, IsOptional, IsNumber, MaxLength, Min, Max } from 'class-validator';
export class UpdateSettingsDto {
@IsOptional()
@IsString()
@MaxLength(3)
defaultCurrency?: string;
@IsOptional()
@IsString()
@MaxLength(10)
locale?: string;
@IsOptional()
@IsString()
@MaxLength(20)
dateFormat?: string;
@IsOptional()
@IsNumber()
@Min(0)
@Max(6)
weekStartsOn?: number;
}

View file

@ -0,0 +1,20 @@
import { Controller, Get, Put, Body, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, type CurrentUserData } from '@manacore/shared-nestjs-auth';
import { SettingsService } from './settings.service';
import { UpdateSettingsDto } from './dto';
@Controller('settings')
@UseGuards(JwtAuthGuard)
export class SettingsController {
constructor(private readonly settingsService: SettingsService) {}
@Get()
get(@CurrentUser() user: CurrentUserData) {
return this.settingsService.get(user.userId);
}
@Put()
update(@CurrentUser() user: CurrentUserData, @Body() dto: UpdateSettingsDto) {
return this.settingsService.update(user.userId, dto);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { SettingsController } from './settings.controller';
import { SettingsService } from './settings.service';
@Module({
controllers: [SettingsController],
providers: [SettingsService],
exports: [SettingsService],
})
export class SettingsModule {}

View file

@ -0,0 +1,57 @@
import { Injectable, Inject } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { DATABASE_CONNECTION, type Database } from '../db/connection';
import { userSettings } from '../db/schema';
import { UpdateSettingsDto } from './dto';
const DEFAULT_SETTINGS = {
defaultCurrency: 'EUR',
locale: 'de-DE',
dateFormat: 'dd.MM.yyyy',
weekStartsOn: 1, // Monday
};
@Injectable()
export class SettingsService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async get(userId: string) {
const [settings] = await this.db
.select()
.from(userSettings)
.where(eq(userSettings.userId, userId));
if (!settings) {
// Create default settings
const [newSettings] = await this.db
.insert(userSettings)
.values({
userId,
...DEFAULT_SETTINGS,
})
.returning();
return newSettings;
}
return settings;
}
async update(userId: string, dto: UpdateSettingsDto) {
// Ensure settings exist
await this.get(userId);
const [settings] = await this.db
.update(userSettings)
.set({
...(dto.defaultCurrency !== undefined && { defaultCurrency: dto.defaultCurrency }),
...(dto.locale !== undefined && { locale: dto.locale }),
...(dto.dateFormat !== undefined && { dateFormat: dto.dateFormat }),
...(dto.weekStartsOn !== undefined && { weekStartsOn: dto.weekStartsOn }),
updatedAt: new Date(),
})
.where(eq(userSettings.userId, userId))
.returning();
return settings;
}
}

View file

@ -0,0 +1,86 @@
import {
IsString,
IsNotEmpty,
IsOptional,
IsBoolean,
IsNumber,
IsUUID,
IsDateString,
IsArray,
IsIn,
ValidateNested,
MaxLength,
} from 'class-validator';
import { Type } from 'class-transformer';
const TRANSACTION_TYPES = ['income', 'expense'] as const;
const RECURRENCE_FREQUENCIES = ['daily', 'weekly', 'monthly', 'yearly'] as const;
export class RecurrenceRuleDto {
@IsString()
@IsIn(RECURRENCE_FREQUENCIES)
frequency: (typeof RECURRENCE_FREQUENCIES)[number];
@IsNumber()
interval: number;
@IsOptional()
@IsDateString()
endDate?: string;
@IsOptional()
@IsNumber()
count?: number;
}
export class CreateTransactionDto {
@IsUUID()
accountId: string;
@IsOptional()
@IsUUID()
categoryId?: string;
@IsString()
@IsIn(TRANSACTION_TYPES)
type: (typeof TRANSACTION_TYPES)[number];
@IsNumber()
amount: number;
@IsDateString()
date: string;
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@IsOptional()
@IsString()
@MaxLength(200)
payee?: string;
@IsOptional()
@IsBoolean()
isRecurring?: boolean;
@IsOptional()
@ValidateNested()
@Type(() => RecurrenceRuleDto)
recurrenceRule?: RecurrenceRuleDto;
@IsOptional()
@IsBoolean()
isPending?: boolean;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsString()
@MaxLength(3)
currency?: string;
}

View file

@ -0,0 +1,3 @@
export * from './create-transaction.dto';
export * from './update-transaction.dto';
export * from './query-transaction.dto';

View file

@ -0,0 +1,69 @@
import {
IsString,
IsOptional,
IsBoolean,
IsNumber,
IsUUID,
IsDateString,
IsIn,
} from 'class-validator';
import { Transform } from 'class-transformer';
const TRANSACTION_TYPES = ['income', 'expense'] as const;
export class QueryTransactionDto {
@IsOptional()
@IsUUID()
accountId?: string;
@IsOptional()
@IsUUID()
categoryId?: string;
@IsOptional()
@IsString()
@IsIn(TRANSACTION_TYPES)
type?: (typeof TRANSACTION_TYPES)[number];
@IsOptional()
@IsDateString()
startDate?: string;
@IsOptional()
@IsDateString()
endDate?: string;
@IsOptional()
@Transform(({ value }) => parseFloat(value))
@IsNumber()
minAmount?: number;
@IsOptional()
@Transform(({ value }) => parseFloat(value))
@IsNumber()
maxAmount?: number;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
@IsBoolean()
isPending?: boolean;
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
@IsBoolean()
isRecurring?: boolean;
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
@IsNumber()
limit?: number;
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
@IsNumber()
offset?: number;
}

View file

@ -0,0 +1,72 @@
import {
IsString,
IsOptional,
IsBoolean,
IsNumber,
IsUUID,
IsDateString,
IsArray,
IsIn,
ValidateNested,
MaxLength,
} from 'class-validator';
import { Type } from 'class-transformer';
import { RecurrenceRuleDto } from './create-transaction.dto';
const TRANSACTION_TYPES = ['income', 'expense'] as const;
export class UpdateTransactionDto {
@IsOptional()
@IsUUID()
accountId?: string;
@IsOptional()
@IsUUID()
categoryId?: string | null;
@IsOptional()
@IsString()
@IsIn(TRANSACTION_TYPES)
type?: (typeof TRANSACTION_TYPES)[number];
@IsOptional()
@IsNumber()
amount?: number;
@IsOptional()
@IsDateString()
date?: string;
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@IsOptional()
@IsString()
@MaxLength(200)
payee?: string;
@IsOptional()
@IsBoolean()
isRecurring?: boolean;
@IsOptional()
@ValidateNested()
@Type(() => RecurrenceRuleDto)
recurrenceRule?: RecurrenceRuleDto | null;
@IsOptional()
@IsBoolean()
isPending?: boolean;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsString()
@MaxLength(3)
currency?: string;
}

View file

@ -0,0 +1,64 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, type CurrentUserData } from '@manacore/shared-nestjs-auth';
import { TransactionService } from './transaction.service';
import { CreateTransactionDto, UpdateTransactionDto, QueryTransactionDto } from './dto';
@Controller('transactions')
@UseGuards(JwtAuthGuard)
export class TransactionController {
constructor(private readonly transactionService: TransactionService) {}
@Get()
findAll(@CurrentUser() user: CurrentUserData, @Query() query: QueryTransactionDto) {
return this.transactionService.findAll(user.userId, query);
}
@Get('recent')
findRecent(@CurrentUser() user: CurrentUserData, @Query('limit') limit?: number) {
return this.transactionService.findRecent(user.userId, limit);
}
@Get('summary')
getSummary(
@CurrentUser() user: CurrentUserData,
@Query('startDate') startDate: string,
@Query('endDate') endDate: string
) {
return this.transactionService.getSummary(user.userId, startDate, endDate);
}
@Get(':id')
findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
return this.transactionService.findOne(user.userId, id);
}
@Post()
create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateTransactionDto) {
return this.transactionService.create(user.userId, dto);
}
@Put(':id')
update(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateTransactionDto
) {
return this.transactionService.update(user.userId, id, dto);
}
@Delete(':id')
delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
return this.transactionService.delete(user.userId, id);
}
}

View file

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TransactionController } from './transaction.controller';
import { TransactionService } from './transaction.service';
import { AccountModule } from '../account/account.module';
@Module({
imports: [AccountModule],
controllers: [TransactionController],
providers: [TransactionService],
exports: [TransactionService],
})
export class TransactionModule {}

View file

@ -0,0 +1,301 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and, desc, gte, lte, like, or, sql } from 'drizzle-orm';
import { DATABASE_CONNECTION, type Database } from '../db/connection';
import { transactions, accounts, categories } from '../db/schema';
import { AccountService } from '../account/account.service';
import { CreateTransactionDto, UpdateTransactionDto, QueryTransactionDto } from './dto';
@Injectable()
export class TransactionService {
constructor(
@Inject(DATABASE_CONNECTION) private db: Database,
private accountService: AccountService
) {}
async findAll(userId: string, query: QueryTransactionDto) {
const conditions = [eq(transactions.userId, userId)];
if (query.accountId) {
conditions.push(eq(transactions.accountId, query.accountId));
}
if (query.categoryId) {
conditions.push(eq(transactions.categoryId, query.categoryId));
}
if (query.type) {
conditions.push(eq(transactions.type, query.type));
}
if (query.startDate) {
conditions.push(gte(transactions.date, query.startDate));
}
if (query.endDate) {
conditions.push(lte(transactions.date, query.endDate));
}
if (query.minAmount !== undefined) {
conditions.push(gte(transactions.amount, query.minAmount.toString()));
}
if (query.maxAmount !== undefined) {
conditions.push(lte(transactions.amount, query.maxAmount.toString()));
}
if (query.search) {
const searchTerm = `%${query.search}%`;
conditions.push(
or(like(transactions.description, searchTerm), like(transactions.payee, searchTerm))!
);
}
if (query.isPending !== undefined) {
conditions.push(eq(transactions.isPending, query.isPending));
}
if (query.isRecurring !== undefined) {
conditions.push(eq(transactions.isRecurring, query.isRecurring));
}
const limit = query.limit ?? 50;
const offset = query.offset ?? 0;
const result = await this.db
.select({
transaction: transactions,
account: {
id: accounts.id,
name: accounts.name,
type: accounts.type,
currency: accounts.currency,
color: accounts.color,
},
category: {
id: categories.id,
name: categories.name,
type: categories.type,
color: categories.color,
icon: categories.icon,
},
})
.from(transactions)
.leftJoin(accounts, eq(transactions.accountId, accounts.id))
.leftJoin(categories, eq(transactions.categoryId, categories.id))
.where(and(...conditions))
.orderBy(desc(transactions.date), desc(transactions.createdAt))
.limit(limit)
.offset(offset);
// Get total count for pagination
const [{ count }] = await this.db
.select({ count: sql<number>`COUNT(*)` })
.from(transactions)
.where(and(...conditions));
return {
data: result.map((r) => ({
...r.transaction,
account: r.account,
category: r.category,
})),
total: Number(count),
limit,
offset,
};
}
async findOne(userId: string, id: string) {
const [result] = await this.db
.select({
transaction: transactions,
account: {
id: accounts.id,
name: accounts.name,
type: accounts.type,
currency: accounts.currency,
color: accounts.color,
},
category: {
id: categories.id,
name: categories.name,
type: categories.type,
color: categories.color,
icon: categories.icon,
},
})
.from(transactions)
.leftJoin(accounts, eq(transactions.accountId, accounts.id))
.leftJoin(categories, eq(transactions.categoryId, categories.id))
.where(and(eq(transactions.id, id), eq(transactions.userId, userId)));
if (!result) {
throw new NotFoundException(`Transaction with ID ${id} not found`);
}
return {
...result.transaction,
account: result.account,
category: result.category,
};
}
async findRecent(userId: string, limit = 10) {
const result = await this.db
.select({
transaction: transactions,
account: {
id: accounts.id,
name: accounts.name,
type: accounts.type,
currency: accounts.currency,
color: accounts.color,
},
category: {
id: categories.id,
name: categories.name,
type: categories.type,
color: categories.color,
icon: categories.icon,
},
})
.from(transactions)
.leftJoin(accounts, eq(transactions.accountId, accounts.id))
.leftJoin(categories, eq(transactions.categoryId, categories.id))
.where(eq(transactions.userId, userId))
.orderBy(desc(transactions.date), desc(transactions.createdAt))
.limit(limit);
return result.map((r) => ({
...r.transaction,
account: r.account,
category: r.category,
}));
}
async create(userId: string, dto: CreateTransactionDto) {
// Verify account ownership
const account = await this.accountService.findOne(userId, dto.accountId);
const [transaction] = await this.db
.insert(transactions)
.values({
userId,
accountId: dto.accountId,
categoryId: dto.categoryId,
type: dto.type,
amount: dto.amount.toString(),
currency: dto.currency ?? account.currency,
date: dto.date,
description: dto.description,
payee: dto.payee,
isRecurring: dto.isRecurring ?? false,
recurrenceRule: dto.recurrenceRule,
isPending: dto.isPending ?? false,
tags: dto.tags ?? [],
})
.returning();
// Update account balance
const balanceChange = dto.type === 'income' ? dto.amount : -dto.amount;
await this.accountService.updateBalance(userId, dto.accountId, balanceChange);
return this.findOne(userId, transaction.id);
}
async update(userId: string, id: string, dto: UpdateTransactionDto) {
// Get original transaction
const original = await this.findOne(userId, id);
// If amount or type changed, we need to adjust account balance
const oldBalanceEffect =
original.type === 'income' ? parseFloat(original.amount) : -parseFloat(original.amount);
const [transaction] = await this.db
.update(transactions)
.set({
...(dto.accountId !== undefined && { accountId: dto.accountId }),
...(dto.categoryId !== undefined && { categoryId: dto.categoryId }),
...(dto.type !== undefined && { type: dto.type }),
...(dto.amount !== undefined && { amount: dto.amount.toString() }),
...(dto.currency !== undefined && { currency: dto.currency }),
...(dto.date !== undefined && { date: dto.date }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.payee !== undefined && { payee: dto.payee }),
...(dto.isRecurring !== undefined && { isRecurring: dto.isRecurring }),
...(dto.recurrenceRule !== undefined && { recurrenceRule: dto.recurrenceRule }),
...(dto.isPending !== undefined && { isPending: dto.isPending }),
...(dto.tags !== undefined && { tags: dto.tags }),
updatedAt: new Date(),
})
.where(and(eq(transactions.id, id), eq(transactions.userId, userId)))
.returning();
// Calculate new balance effect
const newType = dto.type ?? original.type;
const newAmount = dto.amount ?? parseFloat(original.amount);
const newBalanceEffect = newType === 'income' ? newAmount : -newAmount;
const newAccountId = dto.accountId ?? original.accountId!;
// If account changed, adjust both accounts
if (dto.accountId && dto.accountId !== original.accountId) {
// Reverse on old account
await this.accountService.updateBalance(userId, original.accountId!, -oldBalanceEffect);
// Apply to new account
await this.accountService.updateBalance(userId, dto.accountId, newBalanceEffect);
} else if (dto.amount !== undefined || dto.type !== undefined) {
// Same account, but amount or type changed
const balanceDiff = newBalanceEffect - oldBalanceEffect;
await this.accountService.updateBalance(userId, newAccountId, balanceDiff);
}
return this.findOne(userId, transaction.id);
}
async delete(userId: string, id: string) {
// Get transaction to reverse balance
const transaction = await this.findOne(userId, id);
const balanceEffect =
transaction.type === 'income'
? parseFloat(transaction.amount)
: -parseFloat(transaction.amount);
await this.db
.delete(transactions)
.where(and(eq(transactions.id, id), eq(transactions.userId, userId)));
// Reverse balance effect
await this.accountService.updateBalance(userId, transaction.accountId!, -balanceEffect);
return { success: true };
}
async getSummary(userId: string, startDate: string, endDate: string) {
const result = await this.db
.select({
type: transactions.type,
total: sql<string>`SUM(${transactions.amount})`,
count: sql<number>`COUNT(*)`,
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
gte(transactions.date, startDate),
lte(transactions.date, endDate)
)
)
.groupBy(transactions.type);
const income = result.find((r) => r.type === 'income');
const expense = result.find((r) => r.type === 'expense');
return {
income: parseFloat(income?.total ?? '0'),
expense: parseFloat(expense?.total ?? '0'),
net: parseFloat(income?.total ?? '0') - parseFloat(expense?.total ?? '0'),
incomeCount: Number(income?.count ?? 0),
expenseCount: Number(expense?.count ?? 0),
};
}
}

View file

@ -0,0 +1,29 @@
import {
IsString,
IsOptional,
IsNumber,
IsUUID,
IsDateString,
MaxLength,
Min,
} from 'class-validator';
export class CreateTransferDto {
@IsUUID()
fromAccountId: string;
@IsUUID()
toAccountId: string;
@IsNumber()
@Min(0.01)
amount: number;
@IsDateString()
date: string;
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
}

View file

@ -0,0 +1,2 @@
export * from './create-transfer.dto';
export * from './update-transfer.dto';

View file

@ -0,0 +1,33 @@
import {
IsString,
IsOptional,
IsNumber,
IsUUID,
IsDateString,
MaxLength,
Min,
} from 'class-validator';
export class UpdateTransferDto {
@IsOptional()
@IsUUID()
fromAccountId?: string;
@IsOptional()
@IsUUID()
toAccountId?: string;
@IsOptional()
@IsNumber()
@Min(0.01)
amount?: number;
@IsOptional()
@IsDateString()
date?: string;
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
}

View file

@ -0,0 +1,49 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, type CurrentUserData } from '@manacore/shared-nestjs-auth';
import { TransferService } from './transfer.service';
import { CreateTransferDto, UpdateTransferDto } from './dto';
@Controller('transfers')
@UseGuards(JwtAuthGuard)
export class TransferController {
constructor(private readonly transferService: TransferService) {}
@Get()
findAll(@CurrentUser() user: CurrentUserData) {
return this.transferService.findAll(user.userId);
}
@Get(':id')
findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
return this.transferService.findOne(user.userId, id);
}
@Post()
create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateTransferDto) {
return this.transferService.create(user.userId, dto);
}
@Put(':id')
update(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateTransferDto
) {
return this.transferService.update(user.userId, id, dto);
}
@Delete(':id')
delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
return this.transferService.delete(user.userId, id);
}
}

View file

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TransferController } from './transfer.controller';
import { TransferService } from './transfer.service';
import { AccountModule } from '../account/account.module';
@Module({
imports: [AccountModule],
controllers: [TransferController],
providers: [TransferService],
exports: [TransferService],
})
export class TransferModule {}

View file

@ -0,0 +1,162 @@
import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common';
import { eq, and, desc, sql } from 'drizzle-orm';
import { DATABASE_CONNECTION, type Database } from '../db/connection';
import { transfers, accounts } from '../db/schema';
import { AccountService } from '../account/account.service';
import { CreateTransferDto, UpdateTransferDto } from './dto';
@Injectable()
export class TransferService {
constructor(
@Inject(DATABASE_CONNECTION) private db: Database,
private accountService: AccountService
) {}
async findAll(userId: string) {
const result = await this.db
.select({
transfer: transfers,
fromAccount: {
id: sql<string>`from_acc.id`,
name: sql<string>`from_acc.name`,
currency: sql<string>`from_acc.currency`,
color: sql<string>`from_acc.color`,
},
toAccount: {
id: sql<string>`to_acc.id`,
name: sql<string>`to_acc.name`,
currency: sql<string>`to_acc.currency`,
color: sql<string>`to_acc.color`,
},
})
.from(transfers)
.innerJoin(sql`${accounts} as from_acc`, sql`${transfers.fromAccountId} = from_acc.id`)
.innerJoin(sql`${accounts} as to_acc`, sql`${transfers.toAccountId} = to_acc.id`)
.where(eq(transfers.userId, userId))
.orderBy(desc(transfers.date), desc(transfers.createdAt));
return result.map((r) => ({
...r.transfer,
fromAccount: r.fromAccount,
toAccount: r.toAccount,
}));
}
async findOne(userId: string, id: string) {
const [result] = await this.db
.select({
transfer: transfers,
fromAccount: {
id: sql<string>`from_acc.id`,
name: sql<string>`from_acc.name`,
currency: sql<string>`from_acc.currency`,
color: sql<string>`from_acc.color`,
},
toAccount: {
id: sql<string>`to_acc.id`,
name: sql<string>`to_acc.name`,
currency: sql<string>`to_acc.currency`,
color: sql<string>`to_acc.color`,
},
})
.from(transfers)
.innerJoin(sql`${accounts} as from_acc`, sql`${transfers.fromAccountId} = from_acc.id`)
.innerJoin(sql`${accounts} as to_acc`, sql`${transfers.toAccountId} = to_acc.id`)
.where(and(eq(transfers.id, id), eq(transfers.userId, userId)));
if (!result) {
throw new NotFoundException(`Transfer with ID ${id} not found`);
}
return {
...result.transfer,
fromAccount: result.fromAccount,
toAccount: result.toAccount,
};
}
async create(userId: string, dto: CreateTransferDto) {
if (dto.fromAccountId === dto.toAccountId) {
throw new BadRequestException('Cannot transfer to the same account');
}
// Verify both accounts belong to user
await this.accountService.findOne(userId, dto.fromAccountId);
await this.accountService.findOne(userId, dto.toAccountId);
const [transfer] = await this.db
.insert(transfers)
.values({
userId,
fromAccountId: dto.fromAccountId,
toAccountId: dto.toAccountId,
amount: dto.amount.toString(),
date: dto.date,
description: dto.description,
})
.returning();
// Update account balances
await this.accountService.updateBalance(userId, dto.fromAccountId, -dto.amount);
await this.accountService.updateBalance(userId, dto.toAccountId, dto.amount);
return this.findOne(userId, transfer.id);
}
async update(userId: string, id: string, dto: UpdateTransferDto) {
const original = await this.findOne(userId, id);
const originalAmount = parseFloat(original.amount);
// Verify new accounts if provided
if (dto.fromAccountId) {
await this.accountService.findOne(userId, dto.fromAccountId);
}
if (dto.toAccountId) {
await this.accountService.findOne(userId, dto.toAccountId);
}
const newFromAccountId = dto.fromAccountId ?? original.fromAccountId;
const newToAccountId = dto.toAccountId ?? original.toAccountId;
if (newFromAccountId === newToAccountId) {
throw new BadRequestException('Cannot transfer to the same account');
}
const [transfer] = await this.db
.update(transfers)
.set({
...(dto.fromAccountId !== undefined && { fromAccountId: dto.fromAccountId }),
...(dto.toAccountId !== undefined && { toAccountId: dto.toAccountId }),
...(dto.amount !== undefined && { amount: dto.amount.toString() }),
...(dto.date !== undefined && { date: dto.date }),
...(dto.description !== undefined && { description: dto.description }),
updatedAt: new Date(),
})
.where(and(eq(transfers.id, id), eq(transfers.userId, userId)))
.returning();
// Reverse original transfer
await this.accountService.updateBalance(userId, original.fromAccountId, originalAmount);
await this.accountService.updateBalance(userId, original.toAccountId, -originalAmount);
// Apply new transfer
const newAmount = dto.amount ?? originalAmount;
await this.accountService.updateBalance(userId, newFromAccountId, -newAmount);
await this.accountService.updateBalance(userId, newToAccountId, newAmount);
return this.findOne(userId, transfer.id);
}
async delete(userId: string, id: string) {
const transfer = await this.findOne(userId, id);
const amount = parseFloat(transfer.amount);
await this.db.delete(transfers).where(and(eq(transfers.id, id), eq(transfers.userId, userId)));
// Reverse the transfer
await this.accountService.updateBalance(userId, transfer.fromAccountId, amount);
await this.accountService.updateBalance(userId, transfer.toAccountId, -amount);
return { success: true };
}
}

View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,51 @@
{
"name": "@finance/web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite dev --port 5189",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint .",
"format": "prettier --write .",
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.1.7",
"@types/node": "^20.0.0",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.1.7",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^6.0.0"
},
"dependencies": {
"@finance/shared": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-feedback-service": "workspace:*",
"@manacore/shared-feedback-ui": "workspace:*",
"@manacore/shared-i18n": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-profile-ui": "workspace:*",
"@manacore/shared-subscription-ui": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-theme-ui": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"chart.js": "^4.4.7",
"date-fns": "^4.1.0",
"svelte-chartjs": "^3.1.5",
"svelte-i18n": "^4.0.1"
},
"type": "module"
}

View file

@ -0,0 +1,180 @@
@import 'tailwindcss';
@import '@manacore/shared-tailwind/themes.css';
/* Scan shared packages for Tailwind classes */
@source "../../../packages/shared/src";
@source "../../../../../packages/shared-ui/src";
@source "../../../../../packages/shared-theme-ui/src";
:root {
/* Finance App - Green/Emerald Theme */
--color-primary: #10b981;
--color-primary-hover: #059669;
--color-primary-light: #34d399;
--color-primary-dark: #047857;
--color-secondary: #ecfdf5;
--color-secondary-hover: #d1fae5;
--color-accent: #6ee7b7;
--color-accent-hover: #34d399;
/* Transaction types */
--color-income: #22c55e;
--color-income-bg: #dcfce7;
--color-expense: #ef4444;
--color-expense-bg: #fee2e2;
--color-transfer: #3b82f6;
--color-transfer-bg: #dbeafe;
/* Budget status */
--color-budget-ok: #22c55e;
--color-budget-warning: #eab308;
--color-budget-danger: #ef4444;
--color-budget-over: #dc2626;
/* Account types */
--color-checking: #3b82f6;
--color-savings: #22c55e;
--color-credit-card: #f97316;
--color-cash: #8b5cf6;
--color-investment: #06b6d4;
--color-loan: #ef4444;
}
/* Dark mode overrides */
:root.dark {
--color-secondary: #064e3b;
--color-secondary-hover: #065f46;
--color-income-bg: #14532d;
--color-expense-bg: #7f1d1d;
--color-transfer-bg: #1e3a8a;
}
/* Transaction item styling */
.transaction-item {
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
}
.transaction-item:hover {
transform: translateY(-1px);
}
/* Amount styling */
.amount-income {
color: var(--color-income);
}
.amount-expense {
color: var(--color-expense);
}
.amount-transfer {
color: var(--color-transfer);
}
/* Budget progress bar */
.budget-progress {
height: 8px;
border-radius: 4px;
background-color: var(--color-secondary);
overflow: hidden;
}
.budget-progress-bar {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.budget-ok .budget-progress-bar {
background-color: var(--color-budget-ok);
}
.budget-warning .budget-progress-bar {
background-color: var(--color-budget-warning);
}
.budget-danger .budget-progress-bar {
background-color: var(--color-budget-danger);
}
.budget-over .budget-progress-bar {
background-color: var(--color-budget-over);
}
/* Account card */
.account-card {
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
}
.account-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* Category chip */
.category-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
/* Chart container */
.chart-container {
position: relative;
width: 100%;
height: 300px;
}
/* Currency input */
.currency-input {
font-variant-numeric: tabular-nums;
}
/* Date range picker */
.date-range-picker {
display: flex;
gap: 8px;
align-items: center;
}
/* Quick stats */
.stat-card {
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
}
.stat-card:hover {
transform: translateY(-1px);
}
/* Filter chips */
.filter-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-radius: 9999px;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s ease;
}
.filter-chip:hover {
background-color: var(--color-secondary-hover);
}
.filter-chip.active {
background-color: var(--color-primary);
color: white;
}

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,25 @@
import { apiClient } from './client';
import type { Account, CreateAccountInput, UpdateAccountInput } from '@finance/shared';
export const accountsApi = {
getAll: () => apiClient.get<Account[]>('/accounts'),
getAllIncludingArchived: () => apiClient.get<Account[]>('/accounts/all'),
getOne: (id: string) => apiClient.get<Account>(`/accounts/${id}`),
getTotals: () =>
apiClient.get<{ currency: string; total: number; count: number }[]>('/accounts/totals'),
create: (data: CreateAccountInput) => apiClient.post<Account>('/accounts', data),
update: (id: string, data: UpdateAccountInput) => apiClient.put<Account>(`/accounts/${id}`, data),
delete: (id: string) => apiClient.delete<{ success: boolean }>(`/accounts/${id}`),
archive: (id: string) => apiClient.post<Account>(`/accounts/${id}/archive`),
unarchive: (id: string) => apiClient.post<Account>(`/accounts/${id}/unarchive`),
reorder: (accountIds: string[]) => apiClient.put<Account[]>('/accounts/reorder', { accountIds }),
};

View file

@ -0,0 +1,43 @@
import { apiClient } from './client';
import type { Budget, CreateBudgetInput, UpdateBudgetInput } from '@finance/shared';
// Budget with computed spending fields from API
export interface BudgetWithSpending {
id: string;
userId: string;
categoryId: string | null;
month: number;
year: number;
amount: string;
alertThreshold: string;
rolloverEnabled: boolean;
createdAt: Date;
updatedAt: Date;
spent: number;
remaining: number;
percentage: number;
category?: {
id: string;
name: string;
color: string;
icon?: string;
} | null;
}
export const budgetsApi = {
getAll: () => apiClient.get<Budget[]>('/budgets'),
getByMonth: (year: number, month: number) =>
apiClient.get<BudgetWithSpending[]>(`/budgets/month/${year}/${month}`),
getOne: (id: string) => apiClient.get<Budget>(`/budgets/${id}`),
create: (data: CreateBudgetInput) => apiClient.post<Budget>('/budgets', data),
update: (id: string, data: UpdateBudgetInput) => apiClient.put<Budget>(`/budgets/${id}`, data),
delete: (id: string) => apiClient.delete<{ success: boolean }>(`/budgets/${id}`),
copyFromPreviousMonth: (year: number, month: number) =>
apiClient.post<{ message: string; copied: number }>('/budgets/copy', { year, month }),
};

View file

@ -0,0 +1,30 @@
import { apiClient } from './client';
import type {
Category,
CreateCategoryInput,
UpdateCategoryInput,
CategoryType,
} from '@finance/shared';
export const categoriesApi = {
getAll: (type?: CategoryType) => {
const params = type ? `?type=${type}` : '';
return apiClient.get<Category[]>(`/categories${params}`);
},
getAllIncludingArchived: () => apiClient.get<Category[]>('/categories/all'),
getTree: () => apiClient.get<(Category & { children: Category[] })[]>('/categories/tree'),
getOne: (id: string) => apiClient.get<Category>(`/categories/${id}`),
create: (data: CreateCategoryInput) => apiClient.post<Category>('/categories', data),
update: (id: string, data: UpdateCategoryInput) =>
apiClient.put<Category>(`/categories/${id}`, data),
delete: (id: string) => apiClient.delete<{ success: boolean }>(`/categories/${id}`),
seed: () =>
apiClient.post<{ message: string; seeded: boolean; count?: number }>('/categories/seed'),
};

View file

@ -0,0 +1,61 @@
import { PUBLIC_BACKEND_URL } from '$env/static/public';
class ApiClient {
private baseUrl: string;
private token: string | null = null;
constructor() {
this.baseUrl = PUBLIC_BACKEND_URL || 'http://localhost:3019';
}
setToken(token: string | null) {
this.token = token;
}
private async request<T>(
method: string,
path: string,
body?: unknown,
options?: RequestInit
): Promise<T> {
const url = `${this.baseUrl}/api/v1${path}`;
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(this.token && { Authorization: `Bearer ${this.token}` }),
...options?.headers,
};
const response = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
...options,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || `HTTP ${response.status}`);
}
return response.json();
}
get<T>(path: string, options?: RequestInit): Promise<T> {
return this.request<T>('GET', path, undefined, options);
}
post<T>(path: string, body?: unknown, options?: RequestInit): Promise<T> {
return this.request<T>('POST', path, body, options);
}
put<T>(path: string, body?: unknown, options?: RequestInit): Promise<T> {
return this.request<T>('PUT', path, body, options);
}
delete<T>(path: string, options?: RequestInit): Promise<T> {
return this.request<T>('DELETE', path, undefined, options);
}
}
export const apiClient = new ApiClient();

View file

@ -0,0 +1,25 @@
import { apiClient } from './client';
interface ExchangeRate {
fromCurrency: string;
toCurrency: string;
rate: number;
date: string;
}
export const exchangeRatesApi = {
getAll: (baseCurrency = 'EUR') =>
apiClient.get<ExchangeRate[]>(`/exchange-rates?base=${baseCurrency}`),
getRate: (fromCurrency: string, toCurrency: string) =>
apiClient.get<number>(`/exchange-rates/rate?from=${fromCurrency}&to=${toCurrency}`),
convert: (amount: number, fromCurrency: string, toCurrency: string) =>
apiClient.get<number>(
`/exchange-rates/convert?amount=${amount}&from=${fromCurrency}&to=${toCurrency}`
),
seed: () => apiClient.post<{ message: string; seeded: boolean }>('/exchange-rates/seed'),
fetch: () => apiClient.post<void>('/exchange-rates/fetch'),
};

View file

@ -0,0 +1,9 @@
export { apiClient } from './client';
export { accountsApi } from './accounts';
export { categoriesApi } from './categories';
export { transactionsApi } from './transactions';
export { budgetsApi } from './budgets';
export { transfersApi } from './transfers';
export { reportsApi } from './reports';
export { settingsApi } from './settings';
export { exchangeRatesApi } from './exchange-rates';

View file

@ -0,0 +1,89 @@
import { apiClient } from './client';
import type { DashboardData, MonthlySummary, CategoryBreakdown, TrendData } from '@finance/shared';
interface Dashboard {
totals: { currency: string; amount: number }[];
currentMonth: {
year: number;
month: number;
income: number;
expense: number;
net: number;
};
budgets: {
id: string;
category: { id: string; name: string; color: string } | null;
amount: number;
spent: number;
percentage: number;
}[];
recentTransactions: unknown[];
}
interface CategoryBreakdownResponse {
startDate: string;
endDate: string;
type: string;
total: number;
categories: {
categoryId: string | null;
name: string;
color: string | null;
icon: string | null;
amount: number;
count: number;
percentage: number;
}[];
}
interface TrendsResponse {
months: number;
data: {
year: number;
month: number;
income: number;
expense: number;
net: number;
}[];
averages: {
income: number;
expense: number;
net: number;
};
}
interface CashFlowResponse {
startDate: string;
endDate: string;
startingBalance: number;
endingBalance: number;
data: {
date: string;
balance: number;
income: number;
expense: number;
}[];
}
export const reportsApi = {
getDashboard: () => apiClient.get<Dashboard>('/reports/dashboard'),
getMonthlySummary: (year?: number, month?: number) => {
const params = new URLSearchParams();
if (year) params.append('year', String(year));
if (month) params.append('month', String(month));
const query = params.toString();
return apiClient.get<MonthlySummary>(`/reports/monthly-summary${query ? `?${query}` : ''}`);
},
getCategoryBreakdown: (startDate: string, endDate: string, type?: 'income' | 'expense') => {
const params = new URLSearchParams({ startDate, endDate });
if (type) params.append('type', type);
return apiClient.get<CategoryBreakdownResponse>(`/reports/category-breakdown?${params}`);
},
getTrends: (months = 6) => apiClient.get<TrendsResponse>(`/reports/trends?months=${months}`),
getCashFlow: (startDate: string, endDate: string) =>
apiClient.get<CashFlowResponse>(`/reports/cash-flow?startDate=${startDate}&endDate=${endDate}`),
};

View file

@ -0,0 +1,8 @@
import { apiClient } from './client';
import type { UserSettings, UpdateUserSettingsInput } from '@finance/shared';
export const settingsApi = {
get: () => apiClient.get<UserSettings>('/settings'),
update: (data: UpdateUserSettingsInput) => apiClient.put<UserSettings>('/settings', data),
};

View file

@ -0,0 +1,49 @@
import { apiClient } from './client';
import type {
Transaction,
CreateTransactionInput,
UpdateTransactionInput,
TransactionFilters,
} from '@finance/shared';
interface PaginatedTransactions {
data: Transaction[];
total: number;
limit: number;
offset: number;
}
export const transactionsApi = {
getAll: (filters?: TransactionFilters) => {
const params = new URLSearchParams();
if (filters) {
Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
params.append(key, String(value));
}
});
}
const query = params.toString();
return apiClient.get<PaginatedTransactions>(`/transactions${query ? `?${query}` : ''}`);
},
getRecent: (limit = 10) => apiClient.get<Transaction[]>(`/transactions/recent?limit=${limit}`),
getSummary: (startDate: string, endDate: string) =>
apiClient.get<{
income: number;
expense: number;
net: number;
incomeCount: number;
expenseCount: number;
}>(`/transactions/summary?startDate=${startDate}&endDate=${endDate}`),
getOne: (id: string) => apiClient.get<Transaction>(`/transactions/${id}`),
create: (data: CreateTransactionInput) => apiClient.post<Transaction>('/transactions', data),
update: (id: string, data: UpdateTransactionInput) =>
apiClient.put<Transaction>(`/transactions/${id}`, data),
delete: (id: string) => apiClient.delete<{ success: boolean }>(`/transactions/${id}`),
};

View file

@ -0,0 +1,15 @@
import { apiClient } from './client';
import type { Transfer, CreateTransferInput, UpdateTransferInput } from '@finance/shared';
export const transfersApi = {
getAll: () => apiClient.get<Transfer[]>('/transfers'),
getOne: (id: string) => apiClient.get<Transfer>(`/transfers/${id}`),
create: (data: CreateTransferInput) => apiClient.post<Transfer>('/transfers', data),
update: (id: string, data: UpdateTransferInput) =>
apiClient.put<Transfer>(`/transfers/${id}`, data),
delete: (id: string) => apiClient.delete<{ success: boolean }>(`/transfers/${id}`),
};

View file

@ -0,0 +1,76 @@
<script lang="ts">
import { MANA_APPS, getActiveManaApps } from '@manacore/shared-branding';
let { isOpen = $bindable(false) } = $props();
// Get only active (non-archived) apps
const apps = getActiveManaApps();
function close() {
isOpen = false;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
close();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isOpen}
<!-- Backdrop -->
<button
class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
onclick={close}
aria-label="Close app menu"
tabindex="-1"
></button>
<!-- Slider -->
<div class="fixed left-0 top-0 z-50 h-full w-80 bg-card shadow-xl overflow-y-auto">
<div class="p-4">
<div class="flex items-center justify-between mb-6">
<h2 class="text-lg font-semibold">ManaCore Apps</h2>
<button onclick={close} class="rounded-lg p-2 hover:bg-accent" aria-label="Close">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="grid grid-cols-3 gap-3">
{#each apps as app}
<a
href={app.url || '#'}
class="flex flex-col items-center gap-2 rounded-lg p-3 hover:bg-accent transition-colors {app.comingSoon
? 'opacity-50'
: ''}"
target="_blank"
rel="noopener noreferrer"
>
<div
class="h-12 w-12 rounded-xl flex items-center justify-center overflow-hidden"
style="background-color: {app.color}20;"
>
<img src={app.icon} alt={app.name} class="h-8 w-8" />
</div>
<span class="text-xs text-center font-medium">{app.name}</span>
</a>
{/each}
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,53 @@
<script lang="ts">
let isOpen = $state(false);
let currentLang = $state('de');
const languages = [
{ code: 'de', label: 'Deutsch', flag: '🇩🇪' },
{ code: 'en', label: 'English', flag: '🇬🇧' },
];
function selectLanguage(code: string) {
currentLang = code;
isOpen = false;
// TODO: Implement language switching
}
function handleClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement;
if (!target.closest('.language-selector')) {
isOpen = false;
}
}
</script>
<svelte:window onclick={handleClickOutside} />
<div class="language-selector relative">
<button
onclick={() => (isOpen = !isOpen)}
class="flex items-center gap-1 rounded-lg px-2 py-1 hover:bg-accent"
aria-label="Select language"
>
<span class="text-lg">{languages.find((l) => l.code === currentLang)?.flag}</span>
</button>
{#if isOpen}
<div
class="absolute right-0 top-full mt-1 z-50 rounded-lg border border-border bg-card shadow-lg"
>
{#each languages as lang}
<button
onclick={() => selectLanguage(lang.code)}
class="flex w-full items-center gap-2 px-4 py-2 text-left hover:bg-accent {currentLang ===
lang.code
? 'bg-accent'
: ''}"
>
<span class="text-lg">{lang.flag}</span>
<span class="text-sm">{lang.label}</span>
</button>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,113 @@
import { accountsApi } from '$lib/api';
import type { Account, CreateAccountInput, UpdateAccountInput } from '@finance/shared';
let accounts = $state<Account[]>([]);
let isLoading = $state(false);
let error = $state<string | null>(null);
export const accountsStore = {
get accounts() {
return accounts;
},
get isLoading() {
return isLoading;
},
get error() {
return error;
},
get activeAccounts() {
return accounts.filter((a) => !a.isArchived);
},
get totalByCurrency() {
const totals: Record<string, number> = {};
for (const account of accounts.filter((a) => !a.isArchived && a.includeInTotal)) {
const balance = parseFloat(account.balance);
const adjustedBalance =
account.type === 'credit_card' || account.type === 'loan' ? -Math.abs(balance) : balance;
totals[account.currency] = (totals[account.currency] || 0) + adjustedBalance;
}
return totals;
},
async fetchAccounts() {
isLoading = true;
error = null;
try {
accounts = await accountsApi.getAll();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch accounts';
} finally {
isLoading = false;
}
},
async createAccount(data: CreateAccountInput) {
isLoading = true;
error = null;
try {
const newAccount = await accountsApi.create(data);
accounts = [...accounts, newAccount];
return newAccount;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create account';
throw e;
} finally {
isLoading = false;
}
},
async updateAccount(id: string, data: UpdateAccountInput) {
isLoading = true;
error = null;
try {
const updated = await accountsApi.update(id, data);
accounts = accounts.map((a) => (a.id === id ? updated : a));
return updated;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update account';
throw e;
} finally {
isLoading = false;
}
},
async deleteAccount(id: string) {
isLoading = true;
error = null;
try {
await accountsApi.delete(id);
accounts = accounts.filter((a) => a.id !== id);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete account';
throw e;
} finally {
isLoading = false;
}
},
async archiveAccount(id: string) {
try {
const updated = await accountsApi.archive(id);
accounts = accounts.map((a) => (a.id === id ? updated : a));
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to archive account';
throw e;
}
},
async unarchiveAccount(id: string) {
try {
const updated = await accountsApi.unarchive(id);
accounts = accounts.map((a) => (a.id === id ? updated : a));
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to unarchive account';
throw e;
}
},
getAccountById(id: string) {
return accounts.find((a) => a.id === id);
},
};

View file

@ -0,0 +1,61 @@
import { apiClient } from '$lib/api';
interface User {
id: string;
email: string;
name?: string;
}
let user = $state<User | null>(null);
let token = $state<string | null>(null);
let isLoading = $state(true);
export const authStore = {
get user() {
return user;
},
get token() {
return token;
},
get isLoading() {
return isLoading;
},
get isAuthenticated() {
return !!user && !!token;
},
setToken(newToken: string | null) {
token = newToken;
apiClient.setToken(newToken);
if (newToken && typeof window !== 'undefined') {
localStorage.setItem('finance_token', newToken);
} else if (typeof window !== 'undefined') {
localStorage.removeItem('finance_token');
}
},
setUser(newUser: User | null) {
user = newUser;
},
async init() {
if (typeof window === 'undefined') {
isLoading = false;
return;
}
const savedToken = localStorage.getItem('finance_token');
if (savedToken) {
this.setToken(savedToken);
// TODO: Validate token with backend
}
isLoading = false;
},
logout() {
this.setToken(null);
this.setUser(null);
},
};

View file

@ -0,0 +1,110 @@
import { budgetsApi, type BudgetWithSpending } from '$lib/api/budgets';
import type { CreateBudgetInput, UpdateBudgetInput } from '@finance/shared';
let budgets = $state<BudgetWithSpending[]>([]);
let isLoading = $state(false);
let error = $state<string | null>(null);
let selectedMonth = $state(new Date().getMonth() + 1);
let selectedYear = $state(new Date().getFullYear());
export const budgetsStore = {
get budgets() {
return budgets;
},
get isLoading() {
return isLoading;
},
get error() {
return error;
},
get selectedMonth() {
return selectedMonth;
},
get selectedYear() {
return selectedYear;
},
get totalBudgeted() {
return budgets.reduce((sum, b) => sum + parseFloat(b.amount), 0);
},
get totalSpent() {
return budgets.reduce((sum, b) => sum + b.spent, 0);
},
get overBudgetCount() {
return budgets.filter((b) => b.percentage >= 1).length;
},
setMonth(month: number, year: number) {
selectedMonth = month;
selectedYear = year;
},
async fetchBudgets() {
isLoading = true;
error = null;
try {
budgets = await budgetsApi.getByMonth(selectedYear, selectedMonth);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch budgets';
} finally {
isLoading = false;
}
},
async createBudget(data: CreateBudgetInput) {
isLoading = true;
error = null;
try {
await budgetsApi.create(data);
await this.fetchBudgets();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create budget';
throw e;
} finally {
isLoading = false;
}
},
async updateBudget(id: string, data: UpdateBudgetInput) {
isLoading = true;
error = null;
try {
await budgetsApi.update(id, data);
await this.fetchBudgets();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update budget';
throw e;
} finally {
isLoading = false;
}
},
async deleteBudget(id: string) {
isLoading = true;
error = null;
try {
await budgetsApi.delete(id);
budgets = budgets.filter((b) => b.id !== id);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete budget';
throw e;
} finally {
isLoading = false;
}
},
async copyFromPreviousMonth() {
try {
const result = await budgetsApi.copyFromPreviousMonth(selectedYear, selectedMonth);
if (result.copied > 0) {
await this.fetchBudgets();
}
return result;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to copy budgets';
throw e;
}
},
};

View file

@ -0,0 +1,104 @@
import { categoriesApi } from '$lib/api';
import type {
Category,
CreateCategoryInput,
UpdateCategoryInput,
CategoryType,
} from '@finance/shared';
let categories = $state<Category[]>([]);
let isLoading = $state(false);
let error = $state<string | null>(null);
export const categoriesStore = {
get categories() {
return categories;
},
get isLoading() {
return isLoading;
},
get error() {
return error;
},
get expenseCategories() {
return categories.filter((c) => c.type === 'expense' && !c.isArchived);
},
get incomeCategories() {
return categories.filter((c) => c.type === 'income' && !c.isArchived);
},
async fetchCategories(type?: CategoryType) {
isLoading = true;
error = null;
try {
categories = await categoriesApi.getAll(type);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch categories';
} finally {
isLoading = false;
}
},
async createCategory(data: CreateCategoryInput) {
isLoading = true;
error = null;
try {
const newCategory = await categoriesApi.create(data);
categories = [...categories, newCategory];
return newCategory;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create category';
throw e;
} finally {
isLoading = false;
}
},
async updateCategory(id: string, data: UpdateCategoryInput) {
isLoading = true;
error = null;
try {
const updated = await categoriesApi.update(id, data);
categories = categories.map((c) => (c.id === id ? updated : c));
return updated;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update category';
throw e;
} finally {
isLoading = false;
}
},
async deleteCategory(id: string) {
isLoading = true;
error = null;
try {
await categoriesApi.delete(id);
categories = categories.filter((c) => c.id !== id);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete category';
throw e;
} finally {
isLoading = false;
}
},
async seedCategories() {
try {
const result = await categoriesApi.seed();
if (result.seeded) {
await this.fetchCategories();
}
return result;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to seed categories';
throw e;
}
},
getCategoryById(id: string) {
return categories.find((c) => c.id === id);
},
};

View file

@ -0,0 +1,67 @@
import { reportsApi } from '$lib/api';
interface DashboardData {
totals: { currency: string; amount: number }[];
currentMonth: {
year: number;
month: number;
income: number;
expense: number;
net: number;
};
budgets: {
id: string;
category: { id: string; name: string; color: string } | null;
amount: number;
spent: number;
percentage: number;
}[];
recentTransactions: unknown[];
}
let data = $state<DashboardData | null>(null);
let isLoading = $state(false);
let error = $state<string | null>(null);
export const dashboardStore = {
get data() {
return data;
},
get isLoading() {
return isLoading;
},
get error() {
return error;
},
get primaryTotal() {
if (!data?.totals?.length) return 0;
// Return EUR total if available, otherwise first currency
const eurTotal = data.totals.find((t) => t.currency === 'EUR');
return eurTotal?.amount ?? data.totals[0]?.amount ?? 0;
},
get monthlyNet() {
return data?.currentMonth?.net ?? 0;
},
get budgetProgress() {
return data?.budgets ?? [];
},
async fetchDashboard() {
isLoading = true;
error = null;
try {
data = await reportsApi.getDashboard();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch dashboard';
} finally {
isLoading = false;
}
},
async refresh() {
await this.fetchDashboard();
},
};

View file

@ -0,0 +1,7 @@
export { authStore } from './auth.svelte';
export { accountsStore } from './accounts.svelte';
export { categoriesStore } from './categories.svelte';
export { transactionsStore } from './transactions.svelte';
export { budgetsStore } from './budgets.svelte';
export { dashboardStore } from './dashboard.svelte';
export { settingsStore } from './settings.svelte';

View file

@ -0,0 +1,67 @@
import { settingsApi } from '$lib/api';
import type { UserSettings, UpdateUserSettingsInput } from '@finance/shared';
const DEFAULT_SETTINGS: UserSettings = {
id: '',
userId: '',
defaultCurrency: 'EUR',
locale: 'de-DE',
dateFormat: 'dd.MM.yyyy',
weekStartsOn: 1,
createdAt: new Date(),
updatedAt: new Date(),
};
let settings = $state<UserSettings>(DEFAULT_SETTINGS);
let isLoading = $state(false);
let error = $state<string | null>(null);
export const settingsStore = {
get settings() {
return settings;
},
get isLoading() {
return isLoading;
},
get error() {
return error;
},
get currency() {
return settings.defaultCurrency;
},
get locale() {
return settings.locale;
},
get dateFormat() {
return settings.dateFormat;
},
async fetchSettings() {
isLoading = true;
error = null;
try {
settings = await settingsApi.get();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch settings';
} finally {
isLoading = false;
}
},
async updateSettings(data: UpdateUserSettingsInput) {
isLoading = true;
error = null;
try {
settings = await settingsApi.update(data);
return settings;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update settings';
throw e;
} finally {
isLoading = false;
}
},
};

View file

@ -0,0 +1,112 @@
import { transactionsApi } from '$lib/api';
import type {
Transaction,
CreateTransactionInput,
UpdateTransactionInput,
TransactionFilters,
} from '@finance/shared';
let transactions = $state<Transaction[]>([]);
let total = $state(0);
let isLoading = $state(false);
let error = $state<string | null>(null);
let filters = $state<TransactionFilters>({});
export const transactionsStore = {
get transactions() {
return transactions;
},
get total() {
return total;
},
get isLoading() {
return isLoading;
},
get error() {
return error;
},
get filters() {
return filters;
},
setFilters(newFilters: TransactionFilters) {
filters = { ...filters, ...newFilters };
},
clearFilters() {
filters = {};
},
async fetchTransactions(customFilters?: TransactionFilters) {
isLoading = true;
error = null;
try {
const result = await transactionsApi.getAll(customFilters ?? filters);
transactions = result.data;
total = result.total;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch transactions';
} finally {
isLoading = false;
}
},
async fetchRecent(limit = 10) {
try {
return await transactionsApi.getRecent(limit);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch recent transactions';
throw e;
}
},
async createTransaction(data: CreateTransactionInput) {
isLoading = true;
error = null;
try {
const newTransaction = await transactionsApi.create(data);
transactions = [newTransaction, ...transactions];
total += 1;
return newTransaction;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create transaction';
throw e;
} finally {
isLoading = false;
}
},
async updateTransaction(id: string, data: UpdateTransactionInput) {
isLoading = true;
error = null;
try {
const updated = await transactionsApi.update(id, data);
transactions = transactions.map((t) => (t.id === id ? updated : t));
return updated;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update transaction';
throw e;
} finally {
isLoading = false;
}
},
async deleteTransaction(id: string) {
isLoading = true;
error = null;
try {
await transactionsApi.delete(id);
transactions = transactions.filter((t) => t.id !== id);
total -= 1;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete transaction';
throw e;
} finally {
isLoading = false;
}
},
getTransactionById(id: string) {
return transactions.find((t) => t.id === id);
},
};

View file

@ -0,0 +1,75 @@
<script lang="ts">
let email = $state('');
let error = $state<string | null>(null);
let success = $state(false);
let isLoading = $state(false);
async function handleSubmit(e: Event) {
e.preventDefault();
isLoading = true;
error = null;
try {
// TODO: Implement password reset via mana-core-auth
console.log('Reset password for:', email);
error =
'Passwort-Zurücksetzen noch nicht implementiert. Bitte verwenden Sie das ManaCore Auth System.';
} catch (e) {
error = e instanceof Error ? e.message : 'Anfrage fehlgeschlagen';
} finally {
isLoading = false;
}
}
</script>
<svelte:head>
<title>Passwort vergessen | Finance</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center">
<div class="w-full max-w-md space-y-6 rounded-lg border border-border bg-card p-8">
<div class="text-center">
<h1 class="text-2xl font-bold">Passwort vergessen</h1>
<p class="mt-2 text-muted-foreground">
Geben Sie Ihre E-Mail-Adresse ein, um Ihr Passwort zurückzusetzen
</p>
</div>
{#if success}
<div class="rounded-lg bg-green-500/10 p-4 text-center text-green-600">
<p class="font-medium">E-Mail gesendet!</p>
<p class="mt-1 text-sm">Überprüfen Sie Ihren Posteingang für weitere Anweisungen.</p>
</div>
{:else}
<form onsubmit={handleSubmit} class="space-y-4">
<div>
<label for="email" class="mb-1 block text-sm font-medium">E-Mail</label>
<input
id="email"
type="email"
bind:value={email}
required
class="w-full rounded-lg border border-border bg-background px-3 py-2"
placeholder="ihre@email.de"
/>
</div>
{#if error}
<div class="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">{error}</div>
{/if}
<button
type="submit"
disabled={isLoading}
class="w-full rounded-lg bg-primary py-2 text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{isLoading ? 'Senden...' : 'Link senden'}
</button>
</form>
{/if}
<div class="text-center text-sm text-muted-foreground">
<a href="/login" class="text-primary hover:underline">Zurück zur Anmeldung</a>
</div>
</div>
</div>

View file

@ -0,0 +1,85 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores';
let email = $state('');
let password = $state('');
let error = $state<string | null>(null);
let isLoading = $state(false);
async function handleSubmit(e: Event) {
e.preventDefault();
isLoading = true;
error = null;
try {
// TODO: Implement login via mana-core-auth
// For now, just a placeholder
console.log('Login:', email, password);
error = 'Login noch nicht implementiert. Bitte verwenden Sie das ManaCore Auth System.';
} catch (e) {
error = e instanceof Error ? e.message : 'Login fehlgeschlagen';
} finally {
isLoading = false;
}
}
</script>
<svelte:head>
<title>Login | Finance</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center">
<div class="w-full max-w-md space-y-6 rounded-lg border border-border bg-card p-8">
<div class="text-center">
<h1 class="text-2xl font-bold">Anmelden</h1>
<p class="mt-2 text-muted-foreground">Melden Sie sich bei Finance an</p>
</div>
<form onsubmit={handleSubmit} class="space-y-4">
<div>
<label for="email" class="mb-1 block text-sm font-medium">E-Mail</label>
<input
id="email"
type="email"
bind:value={email}
required
class="w-full rounded-lg border border-border bg-background px-3 py-2"
placeholder="ihre@email.de"
/>
</div>
<div>
<label for="password" class="mb-1 block text-sm font-medium">Passwort</label>
<input
id="password"
type="password"
bind:value={password}
required
class="w-full rounded-lg border border-border bg-background px-3 py-2"
placeholder="••••••••"
/>
</div>
{#if error}
<div class="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">{error}</div>
{/if}
<button
type="submit"
disabled={isLoading}
class="w-full rounded-lg bg-primary py-2 text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{isLoading ? 'Anmelden...' : 'Anmelden'}
</button>
</form>
<div class="text-center text-sm">
<a href="/forgot-password" class="text-primary hover:underline">Passwort vergessen?</a>
</div>
<div class="text-center text-sm text-muted-foreground">
Noch kein Konto? <a href="/register" class="text-primary hover:underline">Registrieren</a>
</div>
</div>
</div>

View file

@ -0,0 +1,113 @@
<script lang="ts">
let name = $state('');
let email = $state('');
let password = $state('');
let confirmPassword = $state('');
let error = $state<string | null>(null);
let isLoading = $state(false);
async function handleSubmit(e: Event) {
e.preventDefault();
if (password !== confirmPassword) {
error = 'Passwörter stimmen nicht überein';
return;
}
isLoading = true;
error = null;
try {
// TODO: Implement registration via mana-core-auth
console.log('Register:', name, email, password);
error =
'Registrierung noch nicht implementiert. Bitte verwenden Sie das ManaCore Auth System.';
} catch (e) {
error = e instanceof Error ? e.message : 'Registrierung fehlgeschlagen';
} finally {
isLoading = false;
}
}
</script>
<svelte:head>
<title>Registrieren | Finance</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center">
<div class="w-full max-w-md space-y-6 rounded-lg border border-border bg-card p-8">
<div class="text-center">
<h1 class="text-2xl font-bold">Registrieren</h1>
<p class="mt-2 text-muted-foreground">Erstellen Sie ein neues Konto</p>
</div>
<form onsubmit={handleSubmit} class="space-y-4">
<div>
<label for="name" class="mb-1 block text-sm font-medium">Name</label>
<input
id="name"
type="text"
bind:value={name}
required
class="w-full rounded-lg border border-border bg-background px-3 py-2"
placeholder="Max Mustermann"
/>
</div>
<div>
<label for="email" class="mb-1 block text-sm font-medium">E-Mail</label>
<input
id="email"
type="email"
bind:value={email}
required
class="w-full rounded-lg border border-border bg-background px-3 py-2"
placeholder="ihre@email.de"
/>
</div>
<div>
<label for="password" class="mb-1 block text-sm font-medium">Passwort</label>
<input
id="password"
type="password"
bind:value={password}
required
minlength="8"
class="w-full rounded-lg border border-border bg-background px-3 py-2"
placeholder="••••••••"
/>
</div>
<div>
<label for="confirmPassword" class="mb-1 block text-sm font-medium"
>Passwort bestätigen</label
>
<input
id="confirmPassword"
type="password"
bind:value={confirmPassword}
required
class="w-full rounded-lg border border-border bg-background px-3 py-2"
placeholder="••••••••"
/>
</div>
{#if error}
<div class="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">{error}</div>
{/if}
<button
type="submit"
disabled={isLoading}
class="w-full rounded-lg bg-primary py-2 text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{isLoading ? 'Registrieren...' : 'Registrieren'}
</button>
</form>
<div class="text-center text-sm text-muted-foreground">
Bereits ein Konto? <a href="/login" class="text-primary hover:underline">Anmelden</a>
</div>
</div>
</div>

View file

@ -0,0 +1,186 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { authStore } from '$lib/stores';
import AppSlider from '$lib/components/AppSlider.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
let { children } = $props();
let isAppSliderOpen = $state(false);
let isDark = $state(false);
const navItems = [
{ href: '/', label: 'Dashboard', icon: 'home' },
{ href: '/transactions', label: 'Transaktionen', icon: 'list' },
{ href: '/accounts', label: 'Konten', icon: 'wallet' },
{ href: '/categories', label: 'Kategorien', icon: 'tag' },
{ href: '/budgets', label: 'Budgets', icon: 'pie-chart' },
{ href: '/reports', label: 'Berichte', icon: 'bar-chart' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
];
onMount(() => {
authStore.init();
// Check for dark mode preference
isDark =
document.documentElement.classList.contains('dark') ||
(!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (isDark) {
document.documentElement.classList.add('dark');
}
});
function toggleTheme() {
isDark = !isDark;
if (isDark) {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
}
}
function isActive(href: string) {
if (href === '/') {
return $page.url.pathname === '/';
}
return $page.url.pathname.startsWith(href);
}
</script>
<div class="min-h-screen bg-background text-foreground">
<!-- Header -->
<header class="sticky top-0 z-50 border-b border-border bg-card">
<div class="container mx-auto flex h-16 items-center justify-between px-4">
<div class="flex items-center gap-4">
<button
onclick={() => (isAppSliderOpen = true)}
class="flex items-center gap-2 rounded-lg p-2 hover:bg-accent"
aria-label="Open app menu"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
</button>
<a href="/" class="flex items-center gap-2 font-semibold">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-primary"
>
<line x1="12" y1="1" x2="12" y2="23" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
<span>Finance</span>
</a>
</div>
<nav class="hidden md:flex items-center gap-1">
{#each navItems as item}
<a
href={item.href}
class="px-3 py-2 rounded-md text-sm font-medium transition-colors {isActive(item.href)
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'}"
>
{item.label}
</a>
{/each}
</nav>
<div class="flex items-center gap-2">
<LanguageSelector />
<button
onclick={toggleTheme}
class="rounded-lg p-2 hover:bg-accent"
aria-label="Toggle theme"
>
{#if isDark}
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
{/if}
</button>
</div>
</div>
</header>
<!-- Mobile Navigation -->
<nav class="md:hidden fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-card">
<div class="flex justify-around py-2">
{#each navItems.slice(0, 5) as item}
<a
href={item.href}
class="flex flex-col items-center p-2 text-xs {isActive(item.href)
? 'text-primary'
: 'text-muted-foreground'}"
>
<span class="mb-1">{item.label}</span>
</a>
{/each}
</div>
</nav>
<!-- Main Content -->
<main class="container mx-auto px-4 py-6 pb-20 md:pb-6">
{@render children()}
</main>
</div>
<AppSlider bind:isOpen={isAppSliderOpen} />

View file

@ -0,0 +1,204 @@
<script lang="ts">
import { onMount } from 'svelte';
import { dashboardStore, accountsStore, transactionsStore } from '$lib/stores';
import { formatCurrency } from '@finance/shared';
onMount(async () => {
await Promise.all([dashboardStore.fetchDashboard(), accountsStore.fetchAccounts()]);
});
</script>
<svelte:head>
<title>Dashboard | Finance</title>
</svelte:head>
<div class="space-y-6">
<h1 class="text-2xl font-bold">Dashboard</h1>
{#if dashboardStore.isLoading}
<div class="flex items-center justify-center py-12">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
></div>
</div>
{:else if dashboardStore.error}
<div class="rounded-lg bg-destructive/10 p-4 text-destructive">{dashboardStore.error}</div>
{:else if dashboardStore.data}
<!-- Summary Cards -->
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<!-- Total Balance -->
<div class="rounded-lg border border-border bg-card p-6">
<h3 class="text-sm font-medium text-muted-foreground">Gesamtvermögen</h3>
<p class="mt-2 text-3xl font-bold">{formatCurrency(dashboardStore.primaryTotal)}</p>
{#if dashboardStore.data.totals.length > 1}
<div class="mt-2 text-sm text-muted-foreground">
{#each dashboardStore.data.totals as total}
{#if total.currency !== 'EUR'}
<span class="mr-2">{formatCurrency(total.amount, total.currency)}</span>
{/if}
{/each}
</div>
{/if}
</div>
<!-- Monthly Income -->
<div class="rounded-lg border border-border bg-card p-6">
<h3 class="text-sm font-medium text-muted-foreground">
Einnahmen ({dashboardStore.data.currentMonth.month}/{dashboardStore.data.currentMonth
.year})
</h3>
<p class="mt-2 text-3xl font-bold text-green-500">
{formatCurrency(dashboardStore.data.currentMonth.income)}
</p>
</div>
<!-- Monthly Expense -->
<div class="rounded-lg border border-border bg-card p-6">
<h3 class="text-sm font-medium text-muted-foreground">
Ausgaben ({dashboardStore.data.currentMonth.month}/{dashboardStore.data.currentMonth
.year})
</h3>
<p class="mt-2 text-3xl font-bold text-red-500">
{formatCurrency(dashboardStore.data.currentMonth.expense)}
</p>
</div>
<!-- Monthly Net -->
<div class="rounded-lg border border-border bg-card p-6">
<h3 class="text-sm font-medium text-muted-foreground">
Netto ({dashboardStore.data.currentMonth.month}/{dashboardStore.data.currentMonth.year})
</h3>
<p
class="mt-2 text-3xl font-bold {dashboardStore.monthlyNet >= 0
? 'text-green-500'
: 'text-red-500'}"
>
{formatCurrency(dashboardStore.monthlyNet)}
</p>
</div>
</div>
<div class="grid gap-6 lg:grid-cols-2">
<!-- Accounts -->
<div class="rounded-lg border border-border bg-card p-6">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold">Konten</h2>
<a href="/accounts" class="text-sm text-primary hover:underline">Alle anzeigen</a>
</div>
{#if accountsStore.activeAccounts.length === 0}
<p class="text-muted-foreground">Noch keine Konten vorhanden.</p>
<a href="/accounts" class="mt-2 inline-block text-sm text-primary hover:underline"
>Konto erstellen</a
>
{:else}
<div class="space-y-3">
{#each accountsStore.activeAccounts.slice(0, 5) as account}
<div class="flex items-center justify-between rounded-lg bg-accent/50 p-3">
<div class="flex items-center gap-3">
<div
class="h-10 w-10 rounded-full flex items-center justify-center"
style="background-color: {account.color || '#6b7280'}"
>
<span class="text-white text-sm font-medium">{account.name.charAt(0)}</span>
</div>
<div>
<p class="font-medium">{account.name}</p>
<p class="text-sm text-muted-foreground">{account.type}</p>
</div>
</div>
<p class="font-semibold {parseFloat(account.balance) >= 0 ? '' : 'text-red-500'}">
{formatCurrency(account.balance, account.currency)}
</p>
</div>
{/each}
</div>
{/if}
</div>
<!-- Budget Progress -->
<div class="rounded-lg border border-border bg-card p-6">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold">Budget-Fortschritt</h2>
<a href="/budgets" class="text-sm text-primary hover:underline">Alle anzeigen</a>
</div>
{#if dashboardStore.budgetProgress.length === 0}
<p class="text-muted-foreground">Noch keine Budgets definiert.</p>
<a href="/budgets" class="mt-2 inline-block text-sm text-primary hover:underline"
>Budget erstellen</a
>
{:else}
<div class="space-y-4">
{#each dashboardStore.budgetProgress.slice(0, 4) as budget}
<div>
<div class="mb-1 flex items-center justify-between text-sm">
<span>{budget.category?.name ?? 'Gesamt'}</span>
<span class="text-muted-foreground"
>{formatCurrency(budget.spent)} / {formatCurrency(budget.amount)}</span
>
</div>
<div class="h-2 overflow-hidden rounded-full bg-accent">
<div
class="h-full transition-all {budget.percentage >= 1
? 'bg-red-500'
: budget.percentage >= 0.8
? 'bg-yellow-500'
: 'bg-green-500'}"
style="width: {Math.min(budget.percentage * 100, 100)}%"
></div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- Recent Transactions -->
<div class="rounded-lg border border-border bg-card p-6">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold">Letzte Transaktionen</h2>
<a href="/transactions" class="text-sm text-primary hover:underline">Alle anzeigen</a>
</div>
{#if dashboardStore.data.recentTransactions.length === 0}
<p class="text-muted-foreground">Noch keine Transaktionen vorhanden.</p>
<a href="/transactions" class="mt-2 inline-block text-sm text-primary hover:underline"
>Transaktion erstellen</a
>
{:else}
<div class="space-y-2">
{#each dashboardStore.data.recentTransactions as transaction}
<div class="flex items-center justify-between rounded-lg bg-accent/50 p-3">
<div class="flex items-center gap-3">
<div
class="h-10 w-10 rounded-full flex items-center justify-center"
style="background-color: {(transaction as any).category?.color || '#6b7280'}"
>
<span class="text-white text-sm"
>{(transaction as any).category?.name?.charAt(0) ?? '?'}</span
>
</div>
<div>
<p class="font-medium">
{(transaction as any).description ||
(transaction as any).payee ||
'Keine Beschreibung'}
</p>
<p class="text-sm text-muted-foreground">{(transaction as any).date}</p>
</div>
</div>
<p
class="font-semibold {(transaction as any).type === 'income'
? 'text-green-500'
: 'text-red-500'}"
>
{(transaction as any).type === 'income' ? '+' : '-'}{formatCurrency(
(transaction as any).amount
)}
</p>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,124 @@
<script lang="ts">
import { onMount } from 'svelte';
import { accountsStore } from '$lib/stores';
import { formatCurrency, ACCOUNT_TYPE_LABELS } from '@finance/shared';
let showArchived = $state(false);
onMount(async () => {
await accountsStore.fetchAccounts();
});
const displayedAccounts = $derived(
showArchived ? accountsStore.accounts : accountsStore.activeAccounts
);
const accountsByType = $derived(() => {
const grouped: Record<string, typeof accountsStore.accounts> = {};
for (const account of displayedAccounts) {
if (!grouped[account.type]) {
grouped[account.type] = [];
}
grouped[account.type].push(account);
}
return grouped;
});
</script>
<svelte:head>
<title>Konten | Finance</title>
</svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">Konten</h1>
<div class="flex gap-2">
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={showArchived} class="rounded" />
<span class="text-sm">Archivierte anzeigen</span>
</label>
<a
href="/accounts/new"
class="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
+ Neues Konto
</a>
</div>
</div>
<!-- Totals by Currency -->
<div class="grid gap-4 md:grid-cols-3">
{#each Object.entries(accountsStore.totalByCurrency) as [currency, total]}
<div class="rounded-lg border border-border bg-card p-4">
<h3 class="text-sm font-medium text-muted-foreground">Gesamtsaldo ({currency})</h3>
<p class="mt-1 text-2xl font-bold {total >= 0 ? 'text-green-500' : 'text-red-500'}">
{formatCurrency(total, currency)}
</p>
</div>
{/each}
</div>
<!-- Accounts List -->
{#if accountsStore.isLoading}
<div class="flex items-center justify-center py-12">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
></div>
</div>
{:else if accountsStore.error}
<div class="rounded-lg bg-destructive/10 p-4 text-destructive">{accountsStore.error}</div>
{:else if displayedAccounts.length === 0}
<div class="rounded-lg border border-border bg-card p-12 text-center">
<p class="text-muted-foreground">Noch keine Konten vorhanden.</p>
<a
href="/accounts/new"
class="mt-4 inline-block rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
Erstes Konto erstellen
</a>
</div>
{:else}
{#each Object.entries(accountsByType()) as [type, accounts]}
<div class="space-y-2">
<h2 class="text-lg font-semibold">
{ACCOUNT_TYPE_LABELS[type as keyof typeof ACCOUNT_TYPE_LABELS]?.de ?? type}
</h2>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each accounts as account}
<a
href="/accounts/{account.id}"
class="rounded-lg border border-border bg-card p-4 hover:bg-accent/50 {account.isArchived
? 'opacity-60'
: ''}"
>
<div class="flex items-center gap-3">
<div
class="h-12 w-12 rounded-full flex items-center justify-center"
style="background-color: {account.color || '#6b7280'}"
>
<span class="text-white font-semibold">{account.name.charAt(0)}</span>
</div>
<div class="flex-1">
<p class="font-medium">{account.name}</p>
<p class="text-sm text-muted-foreground">{account.currency}</p>
</div>
</div>
<div class="mt-4">
<p
class="text-2xl font-bold {parseFloat(account.balance) >= 0
? ''
: 'text-red-500'}"
>
{formatCurrency(account.balance, account.currency)}
</p>
{#if account.isArchived}
<span class="text-xs text-muted-foreground">Archiviert</span>
{/if}
</div>
</a>
{/each}
</div>
</div>
{/each}
{/if}
</div>

View file

@ -0,0 +1,184 @@
<script lang="ts">
import { onMount } from 'svelte';
import { budgetsStore, categoriesStore } from '$lib/stores';
import { formatCurrency, calculateBudgetPercentage, getBudgetStatus } from '@finance/shared';
const months = [
'Januar',
'Februar',
'März',
'April',
'Mai',
'Juni',
'Juli',
'August',
'September',
'Oktober',
'November',
'Dezember',
];
onMount(async () => {
await Promise.all([budgetsStore.fetchBudgets(), categoriesStore.fetchCategories()]);
});
function previousMonth() {
if (budgetsStore.selectedMonth === 1) {
budgetsStore.setMonth(12, budgetsStore.selectedYear - 1);
} else {
budgetsStore.setMonth(budgetsStore.selectedMonth - 1, budgetsStore.selectedYear);
}
budgetsStore.fetchBudgets();
}
function nextMonth() {
if (budgetsStore.selectedMonth === 12) {
budgetsStore.setMonth(1, budgetsStore.selectedYear + 1);
} else {
budgetsStore.setMonth(budgetsStore.selectedMonth + 1, budgetsStore.selectedYear);
}
budgetsStore.fetchBudgets();
}
async function copyFromPrevious() {
await budgetsStore.copyFromPreviousMonth();
}
function getStatusColor(percentage: number) {
const status = getBudgetStatus(percentage * 100);
switch (status) {
case 'over':
return 'bg-red-500';
case 'danger':
return 'bg-red-400';
case 'warning':
return 'bg-yellow-500';
default:
return 'bg-green-500';
}
}
</script>
<svelte:head>
<title>Budgets | Finance</title>
</svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">Budgets</h1>
<a
href="/budgets/new"
class="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
+ Neues Budget
</a>
</div>
<!-- Month Selector -->
<div class="flex items-center justify-center gap-4">
<button onclick={previousMonth} class="rounded-lg border border-border p-2 hover:bg-accent"
>&larr;</button
>
<span class="text-lg font-semibold">
{months[budgetsStore.selectedMonth - 1]}
{budgetsStore.selectedYear}
</span>
<button onclick={nextMonth} class="rounded-lg border border-border p-2 hover:bg-accent"
>&rarr;</button
>
</div>
<!-- Summary -->
<div class="grid gap-4 md:grid-cols-3">
<div class="rounded-lg border border-border bg-card p-4">
<h3 class="text-sm font-medium text-muted-foreground">Budgetiert</h3>
<p class="mt-1 text-2xl font-bold">{formatCurrency(budgetsStore.totalBudgeted)}</p>
</div>
<div class="rounded-lg border border-border bg-card p-4">
<h3 class="text-sm font-medium text-muted-foreground">Ausgegeben</h3>
<p class="mt-1 text-2xl font-bold text-red-500">{formatCurrency(budgetsStore.totalSpent)}</p>
</div>
<div class="rounded-lg border border-border bg-card p-4">
<h3 class="text-sm font-medium text-muted-foreground">Verbleibend</h3>
<p
class="mt-1 text-2xl font-bold {budgetsStore.totalBudgeted - budgetsStore.totalSpent >= 0
? 'text-green-500'
: 'text-red-500'}"
>
{formatCurrency(budgetsStore.totalBudgeted - budgetsStore.totalSpent)}
</p>
</div>
</div>
{#if budgetsStore.isLoading}
<div class="flex items-center justify-center py-12">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
></div>
</div>
{:else if budgetsStore.error}
<div class="rounded-lg bg-destructive/10 p-4 text-destructive">{budgetsStore.error}</div>
{:else if budgetsStore.budgets.length === 0}
<div class="rounded-lg border border-border bg-card p-12 text-center">
<p class="text-muted-foreground">Keine Budgets für diesen Monat definiert.</p>
<div class="mt-4 flex justify-center gap-4">
<button
onclick={copyFromPrevious}
class="rounded-lg border border-border px-4 py-2 hover:bg-accent"
>
Vom Vormonat kopieren
</button>
<a
href="/budgets/new"
class="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
Budget erstellen
</a>
</div>
</div>
{:else}
<div class="space-y-4">
{#each budgetsStore.budgets as budget}
<div class="rounded-lg border border-border bg-card p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
{#if budget.category}
<div
class="h-10 w-10 rounded-full flex items-center justify-center"
style="background-color: {budget.category.color || '#6b7280'}"
>
<span class="text-white font-medium">{budget.category.name.charAt(0)}</span>
</div>
<span class="font-medium">{budget.category.name}</span>
{:else}
<div class="h-10 w-10 rounded-full bg-gray-500 flex items-center justify-center">
<span class="text-white font-medium">G</span>
</div>
<span class="font-medium">Gesamtbudget</span>
{/if}
</div>
<div class="text-right">
<p class="font-semibold">
{formatCurrency(budget.spent)} / {formatCurrency(budget.amount)}
</p>
<p class="text-sm {budget.remaining >= 0 ? 'text-green-500' : 'text-red-500'}">
{budget.remaining >= 0
? `${formatCurrency(budget.remaining)} übrig`
: `${formatCurrency(Math.abs(budget.remaining))} über Budget`}
</p>
</div>
</div>
<div class="mt-3 h-3 overflow-hidden rounded-full bg-accent">
<div
class="h-full transition-all {getStatusColor(budget.percentage)}"
style="width: {Math.min(budget.percentage * 100, 100)}%"
></div>
</div>
<p class="mt-1 text-right text-sm text-muted-foreground">
{Math.round(budget.percentage * 100)}%
</p>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,110 @@
<script lang="ts">
import { onMount } from 'svelte';
import { categoriesStore } from '$lib/stores';
onMount(async () => {
await categoriesStore.fetchCategories();
});
async function seedCategories() {
await categoriesStore.seedCategories();
}
</script>
<svelte:head>
<title>Kategorien | Finance</title>
</svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">Kategorien</h1>
<div class="flex gap-2">
{#if categoriesStore.categories.length === 0}
<button
onclick={seedCategories}
class="rounded-lg border border-border px-4 py-2 hover:bg-accent"
>
Standard-Kategorien laden
</button>
{/if}
<a
href="/categories/new"
class="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
+ Neue Kategorie
</a>
</div>
</div>
{#if categoriesStore.isLoading}
<div class="flex items-center justify-center py-12">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
></div>
</div>
{:else if categoriesStore.error}
<div class="rounded-lg bg-destructive/10 p-4 text-destructive">{categoriesStore.error}</div>
{:else}
<div class="grid gap-6 lg:grid-cols-2">
<!-- Expense Categories -->
<div class="rounded-lg border border-border bg-card p-6">
<h2 class="mb-4 text-lg font-semibold text-red-500">Ausgaben</h2>
{#if categoriesStore.expenseCategories.length === 0}
<p class="text-muted-foreground">Keine Ausgaben-Kategorien vorhanden.</p>
{:else}
<div class="space-y-2">
{#each categoriesStore.expenseCategories as category}
<a
href="/categories/{category.id}"
class="flex items-center gap-3 rounded-lg p-3 hover:bg-accent/50"
>
<div
class="h-10 w-10 rounded-full flex items-center justify-center"
style="background-color: {category.color || '#ef4444'}"
>
<span class="text-white font-medium">{category.name.charAt(0)}</span>
</div>
<div class="flex-1">
<p class="font-medium">{category.name}</p>
{#if category.isSystem}
<span class="text-xs text-muted-foreground">Standard</span>
{/if}
</div>
</a>
{/each}
</div>
{/if}
</div>
<!-- Income Categories -->
<div class="rounded-lg border border-border bg-card p-6">
<h2 class="mb-4 text-lg font-semibold text-green-500">Einnahmen</h2>
{#if categoriesStore.incomeCategories.length === 0}
<p class="text-muted-foreground">Keine Einnahmen-Kategorien vorhanden.</p>
{:else}
<div class="space-y-2">
{#each categoriesStore.incomeCategories as category}
<a
href="/categories/{category.id}"
class="flex items-center gap-3 rounded-lg p-3 hover:bg-accent/50"
>
<div
class="h-10 w-10 rounded-full flex items-center justify-center"
style="background-color: {category.color || '#22c55e'}"
>
<span class="text-white font-medium">{category.name.charAt(0)}</span>
</div>
<div class="flex-1">
<p class="font-medium">{category.name}</p>
{#if category.isSystem}
<span class="text-xs text-muted-foreground">Standard</span>
{/if}
</div>
</a>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,124 @@
<script lang="ts">
let type = $state<'bug' | 'feature' | 'other'>('feature');
let message = $state('');
let email = $state('');
let isSubmitting = $state(false);
let success = $state(false);
let error = $state<string | null>(null);
async function handleSubmit(e: Event) {
e.preventDefault();
isSubmitting = true;
error = null;
try {
// TODO: Implement feedback submission
console.log('Feedback:', { type, message, email });
success = true;
} catch (e) {
error = e instanceof Error ? e.message : 'Feedback konnte nicht gesendet werden';
} finally {
isSubmitting = false;
}
}
</script>
<svelte:head>
<title>Feedback | Finance</title>
</svelte:head>
<div class="mx-auto max-w-2xl space-y-6">
<h1 class="text-2xl font-bold">Feedback</h1>
{#if success}
<div class="rounded-lg border border-green-500/50 bg-green-500/10 p-8 text-center">
<div class="mb-4 text-4xl"></div>
<h2 class="text-xl font-semibold text-green-600">Vielen Dank für Ihr Feedback!</h2>
<p class="mt-2 text-muted-foreground">Wir werden uns Ihre Nachricht ansehen.</p>
<a
href="/"
class="mt-4 inline-block rounded-lg bg-primary px-6 py-2 text-primary-foreground hover:bg-primary/90"
>
Zurück zur Startseite
</a>
</div>
{:else}
<form onsubmit={handleSubmit} class="space-y-6">
<div class="rounded-lg border border-border bg-card p-6">
<h2 class="mb-4 text-lg font-semibold">Was möchten Sie uns mitteilen?</h2>
<div class="mb-4 flex gap-2">
<button
type="button"
onclick={() => (type = 'bug')}
class="rounded-lg px-4 py-2 {type === 'bug'
? 'bg-red-500 text-white'
: 'border border-border hover:bg-accent'}"
>
🐛 Bug melden
</button>
<button
type="button"
onclick={() => (type = 'feature')}
class="rounded-lg px-4 py-2 {type === 'feature'
? 'bg-blue-500 text-white'
: 'border border-border hover:bg-accent'}"
>
💡 Feature-Wunsch
</button>
<button
type="button"
onclick={() => (type = 'other')}
class="rounded-lg px-4 py-2 {type === 'other'
? 'bg-gray-500 text-white'
: 'border border-border hover:bg-accent'}"
>
💬 Sonstiges
</button>
</div>
<div class="space-y-4">
<div>
<label for="message" class="mb-1 block text-sm font-medium">Ihre Nachricht</label>
<textarea
id="message"
bind:value={message}
required
rows="6"
class="w-full rounded-lg border border-border bg-background px-3 py-2"
placeholder="Beschreiben Sie Ihr Anliegen..."
></textarea>
</div>
<div>
<label for="email" class="mb-1 block text-sm font-medium">E-Mail (optional)</label>
<input
id="email"
type="email"
bind:value={email}
class="w-full rounded-lg border border-border bg-background px-3 py-2"
placeholder="ihre@email.de"
/>
<p class="mt-1 text-xs text-muted-foreground">
Falls wir Rückfragen haben oder Sie über Updates informieren möchten
</p>
</div>
</div>
</div>
{#if error}
<div class="rounded-lg bg-destructive/10 p-4 text-destructive">{error}</div>
{/if}
<div class="flex justify-end">
<button
type="submit"
disabled={isSubmitting || !message.trim()}
class="rounded-lg bg-primary px-6 py-2 text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{isSubmitting ? 'Senden...' : 'Feedback senden'}
</button>
</div>
</form>
{/if}
</div>

View file

@ -0,0 +1,129 @@
<script lang="ts">
import { onMount } from 'svelte';
import { reportsApi } from '$lib/api';
import { formatCurrency, getMonthDateRange, getCurrentMonthYear } from '@finance/shared';
let trends = $state<
{ year: number; month: number; income: number; expense: number; net: number }[]
>([]);
let isLoading = $state(true);
let error = $state<string | null>(null);
const months = [
'Jan',
'Feb',
'Mär',
'Apr',
'Mai',
'Jun',
'Jul',
'Aug',
'Sep',
'Okt',
'Nov',
'Dez',
];
onMount(async () => {
try {
const result = await reportsApi.getTrends(6);
trends = result.data;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load reports';
} finally {
isLoading = false;
}
});
const maxAmount = $derived(Math.max(...trends.flatMap((t) => [t.income, t.expense]), 1));
</script>
<svelte:head>
<title>Berichte | Finance</title>
</svelte:head>
<div class="space-y-6">
<h1 class="text-2xl font-bold">Berichte</h1>
<!-- Quick Links -->
<div class="grid gap-4 md:grid-cols-3">
<a
href="/reports/monthly"
class="rounded-lg border border-border bg-card p-6 hover:bg-accent/50"
>
<h3 class="font-semibold">Monatsübersicht</h3>
<p class="mt-1 text-sm text-muted-foreground">Detaillierte Aufschlüsselung nach Kategorie</p>
</a>
<a
href="/reports/trends"
class="rounded-lg border border-border bg-card p-6 hover:bg-accent/50"
>
<h3 class="font-semibold">Trends</h3>
<p class="mt-1 text-sm text-muted-foreground">Ausgaben und Einnahmen über Zeit</p>
</a>
<a href="/settings" class="rounded-lg border border-border bg-card p-6 hover:bg-accent/50">
<h3 class="font-semibold">Export</h3>
<p class="mt-1 text-sm text-muted-foreground">Daten als CSV exportieren</p>
</a>
</div>
<!-- Trends Preview -->
{#if isLoading}
<div class="flex items-center justify-center py-12">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
></div>
</div>
{:else if error}
<div class="rounded-lg bg-destructive/10 p-4 text-destructive">{error}</div>
{:else}
<div class="rounded-lg border border-border bg-card p-6">
<h2 class="mb-6 text-lg font-semibold">Letzte 6 Monate</h2>
<!-- Simple Bar Chart -->
<div class="space-y-4">
{#each trends as month}
<div class="space-y-1">
<div class="flex items-center justify-between text-sm">
<span class="font-medium">{months[month.month - 1]} {month.year}</span>
<span class="text-muted-foreground"
>Netto: <span class={month.net >= 0 ? 'text-green-500' : 'text-red-500'}
>{formatCurrency(month.net)}</span
></span
>
</div>
<div class="flex gap-2">
<div class="flex-1">
<div class="h-4 overflow-hidden rounded bg-green-100 dark:bg-green-900/30">
<div
class="h-full bg-green-500"
style="width: {(month.income / maxAmount) * 100}%"
></div>
</div>
<span class="text-xs text-green-600">{formatCurrency(month.income)}</span>
</div>
<div class="flex-1">
<div class="h-4 overflow-hidden rounded bg-red-100 dark:bg-red-900/30">
<div
class="h-full bg-red-500"
style="width: {(month.expense / maxAmount) * 100}%"
></div>
</div>
<span class="text-xs text-red-600">{formatCurrency(month.expense)}</span>
</div>
</div>
</div>
{/each}
</div>
<div class="mt-4 flex items-center gap-4 text-sm text-muted-foreground">
<span class="flex items-center gap-1"
><span class="h-3 w-3 rounded bg-green-500"></span> Einnahmen</span
>
<span class="flex items-center gap-1"
><span class="h-3 w-3 rounded bg-red-500"></span> Ausgaben</span
>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,146 @@
<script lang="ts">
import { onMount } from 'svelte';
import { settingsStore } from '$lib/stores';
import { CURRENCIES, DATE_FORMATS, WEEK_START_OPTIONS } from '@finance/shared';
import type { UpdateUserSettingsInput } from '@finance/shared';
let isSaving = $state(false);
let successMessage = $state<string | null>(null);
onMount(async () => {
await settingsStore.fetchSettings();
});
async function saveSettings(e: Event) {
e.preventDefault();
isSaving = true;
successMessage = null;
try {
const data: UpdateUserSettingsInput = {
defaultCurrency: settingsStore.settings.defaultCurrency,
locale: settingsStore.settings.locale,
dateFormat: settingsStore.settings.dateFormat,
weekStartsOn: settingsStore.settings.weekStartsOn,
};
await settingsStore.updateSettings(data);
successMessage = 'Einstellungen gespeichert!';
setTimeout(() => (successMessage = null), 3000);
} catch (e) {
// Error handled by store
} finally {
isSaving = false;
}
}
</script>
<svelte:head>
<title>Einstellungen | Finance</title>
</svelte:head>
<div class="mx-auto max-w-2xl space-y-6">
<h1 class="text-2xl font-bold">Einstellungen</h1>
{#if settingsStore.isLoading}
<div class="flex items-center justify-center py-12">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
></div>
</div>
{:else}
<form onsubmit={saveSettings} class="space-y-6">
<div class="rounded-lg border border-border bg-card p-6">
<h2 class="mb-4 text-lg font-semibold">Regionale Einstellungen</h2>
<div class="space-y-4">
<div>
<label for="currency" class="mb-1 block text-sm font-medium">Standard-Währung</label>
<select
id="currency"
bind:value={settingsStore.settings.defaultCurrency}
class="w-full rounded-lg border border-border bg-background px-3 py-2"
>
{#each CURRENCIES as currency}
<option value={currency.code}
>{currency.code} - {currency.name.de} ({currency.symbol})</option
>
{/each}
</select>
</div>
<div>
<label for="locale" class="mb-1 block text-sm font-medium">Sprache / Region</label>
<select
id="locale"
bind:value={settingsStore.settings.locale}
class="w-full rounded-lg border border-border bg-background px-3 py-2"
>
<option value="de-DE">Deutsch (Deutschland)</option>
<option value="de-AT">Deutsch (Österreich)</option>
<option value="de-CH">Deutsch (Schweiz)</option>
<option value="en-US">English (US)</option>
<option value="en-GB">English (UK)</option>
</select>
</div>
<div>
<label for="dateFormat" class="mb-1 block text-sm font-medium">Datumsformat</label>
<select
id="dateFormat"
bind:value={settingsStore.settings.dateFormat}
class="w-full rounded-lg border border-border bg-background px-3 py-2"
>
{#each DATE_FORMATS as format}
<option value={format.value}>{format.label}</option>
{/each}
</select>
</div>
<div>
<label for="weekStart" class="mb-1 block text-sm font-medium">Woche beginnt am</label>
<select
id="weekStart"
bind:value={settingsStore.settings.weekStartsOn}
class="w-full rounded-lg border border-border bg-background px-3 py-2"
>
{#each WEEK_START_OPTIONS as option}
<option value={option.value}>{option.label.de}</option>
{/each}
</select>
</div>
</div>
</div>
{#if settingsStore.error}
<div class="rounded-lg bg-destructive/10 p-4 text-destructive">{settingsStore.error}</div>
{/if}
{#if successMessage}
<div class="rounded-lg bg-green-500/10 p-4 text-green-600">{successMessage}</div>
{/if}
<div class="flex justify-end">
<button
type="submit"
disabled={isSaving}
class="rounded-lg bg-primary px-6 py-2 text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{isSaving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</form>
<!-- Danger Zone -->
<div class="rounded-lg border border-destructive bg-destructive/5 p-6">
<h2 class="mb-4 text-lg font-semibold text-destructive">Gefahrenzone</h2>
<p class="mb-4 text-sm text-muted-foreground">
Diese Aktionen können nicht rückgängig gemacht werden.
</p>
<button
class="rounded-lg border border-destructive px-4 py-2 text-destructive hover:bg-destructive/10"
>
Alle Daten löschen
</button>
</div>
{/if}
</div>

View file

@ -0,0 +1,212 @@
<script lang="ts">
import { onMount } from 'svelte';
import { transactionsStore, categoriesStore, accountsStore } from '$lib/stores';
import { formatCurrency, formatDate } from '@finance/shared';
import type { TransactionFilters } from '@finance/shared';
let showFilters = $state(false);
let filters = $state<TransactionFilters>({});
onMount(async () => {
await Promise.all([
transactionsStore.fetchTransactions(),
categoriesStore.fetchCategories(),
accountsStore.fetchAccounts(),
]);
});
async function applyFilters() {
transactionsStore.setFilters(filters);
await transactionsStore.fetchTransactions();
}
async function clearFilters() {
filters = {};
transactionsStore.clearFilters();
await transactionsStore.fetchTransactions();
}
</script>
<svelte:head>
<title>Transaktionen | Finance</title>
</svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">Transaktionen</h1>
<div class="flex gap-2">
<button
onclick={() => (showFilters = !showFilters)}
class="rounded-lg border border-border px-4 py-2 hover:bg-accent"
>
{showFilters ? 'Filter ausblenden' : 'Filter anzeigen'}
</button>
<a
href="/transactions/new"
class="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
+ Neu
</a>
</div>
</div>
<!-- Filters -->
{#if showFilters}
<div class="rounded-lg border border-border bg-card p-4">
<div class="grid gap-4 md:grid-cols-4">
<div>
<label class="mb-1 block text-sm font-medium">Konto</label>
<select
bind:value={filters.accountId}
class="w-full rounded-lg border border-border bg-background px-3 py-2"
>
<option value="">Alle Konten</option>
{#each accountsStore.activeAccounts as account}
<option value={account.id}>{account.name}</option>
{/each}
</select>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Kategorie</label>
<select
bind:value={filters.categoryId}
class="w-full rounded-lg border border-border bg-background px-3 py-2"
>
<option value="">Alle Kategorien</option>
{#each categoriesStore.categories as category}
<option value={category.id}>{category.name}</option>
{/each}
</select>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Typ</label>
<select
bind:value={filters.type}
class="w-full rounded-lg border border-border bg-background px-3 py-2"
>
<option value="">Alle</option>
<option value="income">Einnahme</option>
<option value="expense">Ausgabe</option>
</select>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Suche</label>
<input
type="text"
bind:value={filters.search}
placeholder="Beschreibung, Empfänger..."
class="w-full rounded-lg border border-border bg-background px-3 py-2"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Von</label>
<input
type="date"
bind:value={filters.startDate}
class="w-full rounded-lg border border-border bg-background px-3 py-2"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Bis</label>
<input
type="date"
bind:value={filters.endDate}
class="w-full rounded-lg border border-border bg-background px-3 py-2"
/>
</div>
</div>
<div class="mt-4 flex gap-2">
<button
onclick={applyFilters}
class="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
Filter anwenden
</button>
<button
onclick={clearFilters}
class="rounded-lg border border-border px-4 py-2 hover:bg-accent"
>
Zurücksetzen
</button>
</div>
</div>
{/if}
<!-- Transaction List -->
{#if transactionsStore.isLoading}
<div class="flex items-center justify-center py-12">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
></div>
</div>
{:else if transactionsStore.error}
<div class="rounded-lg bg-destructive/10 p-4 text-destructive">{transactionsStore.error}</div>
{:else if transactionsStore.transactions.length === 0}
<div class="rounded-lg border border-border bg-card p-12 text-center">
<p class="text-muted-foreground">Keine Transaktionen gefunden.</p>
<a
href="/transactions/new"
class="mt-4 inline-block rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
Erste Transaktion erstellen
</a>
</div>
{:else}
<div class="rounded-lg border border-border bg-card">
<div class="divide-y divide-border">
{#each transactionsStore.transactions as transaction}
<a
href="/transactions/{transaction.id}"
class="flex items-center justify-between p-4 hover:bg-accent/50"
>
<div class="flex items-center gap-4">
<div
class="h-10 w-10 rounded-full flex items-center justify-center"
style="background-color: {transaction.category?.color || '#6b7280'}"
>
<span class="text-white text-sm font-medium"
>{transaction.category?.name?.charAt(0) ?? '?'}</span
>
</div>
<div>
<p class="font-medium">
{transaction.description || transaction.payee || 'Keine Beschreibung'}
</p>
<p class="text-sm text-muted-foreground">
{transaction.category?.name ?? 'Keine Kategorie'}{formatDate(transaction.date)}
{transaction.account?.name}
</p>
</div>
</div>
<div class="text-right">
<p
class="font-semibold {transaction.type === 'income'
? 'text-green-500'
: 'text-red-500'}"
>
{transaction.type === 'income' ? '+' : '-'}{formatCurrency(
transaction.amount,
transaction.currency
)}
</p>
{#if transaction.isPending}
<span class="text-xs text-yellow-500">Ausstehend</span>
{/if}
</div>
</a>
{/each}
</div>
</div>
<!-- Pagination info -->
<div class="text-center text-sm text-muted-foreground">
{transactionsStore.transactions.length} von {transactionsStore.total} Transaktionen
</div>
{/if}
</div>

View file

@ -0,0 +1,12 @@
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
},
};
export default config;

View file

@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

View file

@ -0,0 +1,47 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
port: 5189,
strictPort: true,
},
ssr: {
noExternal: [
'@finance/shared',
'@manacore/shared-icons',
'@manacore/shared-ui',
'@manacore/shared-tailwind',
'@manacore/shared-theme',
'@manacore/shared-theme-ui',
'@manacore/shared-feedback-ui',
'@manacore/shared-feedback-service',
'@manacore/shared-feedback-types',
'@manacore/shared-auth',
'@manacore/shared-auth-ui',
'@manacore/shared-branding',
'@manacore/shared-subscription-ui',
'chart.js',
'svelte-chartjs',
],
},
optimizeDeps: {
exclude: [
'@finance/shared',
'@manacore/shared-icons',
'@manacore/shared-ui',
'@manacore/shared-tailwind',
'@manacore/shared-theme',
'@manacore/shared-theme-ui',
'@manacore/shared-feedback-ui',
'@manacore/shared-feedback-service',
'@manacore/shared-feedback-types',
'@manacore/shared-auth',
'@manacore/shared-auth-ui',
'@manacore/shared-branding',
'@manacore/shared-subscription-ui',
],
},
});

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