chore: archive finance, mail, moodlit apps and rename voxel-lava

- Move finance, mail, moodlit to apps-archived for later development
- Rename games/voxel-lava to games/voxelava

🤖 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-05 13:13:15 +01:00
parent c3c272abc9
commit ace7fa8f7f
427 changed files with 0 additions and 0 deletions

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 };
}
}