mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 19:06:42 +02:00
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:
parent
c3c272abc9
commit
ace7fa8f7f
427 changed files with 0 additions and 0 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './create-account.dto';
|
||||
export * from './update-account.dto';
|
||||
|
|
@ -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;
|
||||
}
|
||||
34
apps-archived/finance/apps/backend/src/app.module.ts
Normal file
34
apps-archived/finance/apps/backend/src/app.module.ts
Normal 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 {}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
220
apps-archived/finance/apps/backend/src/budget/budget.service.ts
Normal file
220
apps-archived/finance/apps/backend/src/budget/budget.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './create-budget.dto';
|
||||
export * from './update-budget.dto';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './create-category.dto';
|
||||
export * from './update-category.dto';
|
||||
|
|
@ -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;
|
||||
}
|
||||
29
apps-archived/finance/apps/backend/src/db/connection.ts
Normal file
29
apps-archived/finance/apps/backend/src/db/connection.ts
Normal 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';
|
||||
26
apps-archived/finance/apps/backend/src/db/database.module.ts
Normal file
26
apps-archived/finance/apps/backend/src/db/database.module.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
45
apps-archived/finance/apps/backend/src/main.ts
Normal file
45
apps-archived/finance/apps/backend/src/main.ts
Normal 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();
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
396
apps-archived/finance/apps/backend/src/report/report.service.ts
Normal file
396
apps-archived/finance/apps/backend/src/report/report.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './update-settings.dto';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export * from './create-transaction.dto';
|
||||
export * from './update-transaction.dto';
|
||||
export * from './query-transaction.dto';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './create-transfer.dto';
|
||||
export * from './update-transfer.dto';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue