initial: standalone repo from monorepo split
This commit is contained in:
9
src/expense/dto/create-expense.dto.ts
Normal file
9
src/expense/dto/create-expense.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { IsString, IsNumber, IsPositive, IsOptional, IsIn, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateExpenseDto {
|
||||
@IsString() @IsIn(['utilities', 'supplies', 'salary', 'maintenance', 'transport', 'equipment', 'other']) category: string;
|
||||
@IsString() @MinLength(3) description: string;
|
||||
@IsNumber() @IsPositive() amount: number;
|
||||
@IsOptional() @IsString() expenseDate?: string;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
68
src/expense/expense.controller.ts
Normal file
68
src/expense/expense.controller.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Param, Body, Query, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ExpenseService } from './expense.service';
|
||||
import { CreateExpenseDto } from './dto/create-expense.dto';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { TenantGuard } from '../common/guards/tenant.guard';
|
||||
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
|
||||
import { IsString, IsNumber, IsPositive, IsOptional, IsIn, MinLength } from 'class-validator';
|
||||
|
||||
class CreateRecurringDto {
|
||||
@IsString() @IsIn(['utilities', 'supplies', 'salary', 'maintenance', 'transport', 'equipment', 'other']) category: string;
|
||||
@IsString() @MinLength(3) description: string;
|
||||
@IsNumber() @IsPositive() amount: number;
|
||||
@IsOptional() @IsString() @IsIn(['monthly', 'quarterly', 'yearly']) frequency?: string;
|
||||
}
|
||||
|
||||
@Controller('expenses')
|
||||
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
|
||||
@Roles('manager')
|
||||
export class ExpenseController {
|
||||
constructor(private readonly expenseService: ExpenseService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserPayload, @Query('status') status?: string, @Query('category') category?: string) {
|
||||
return this.expenseService.findAll(user.tenantId, { status, category });
|
||||
}
|
||||
|
||||
@Get('summary')
|
||||
async getSummary(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.expenseService.getSummary(user.tenantId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserPayload, @Body() dto: CreateExpenseDto) {
|
||||
return this.expenseService.create(user.tenantId, user.sub, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/approve')
|
||||
async approve(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string) {
|
||||
return this.expenseService.approve(user.tenantId, id, user.sub);
|
||||
}
|
||||
|
||||
@Patch(':id/reject')
|
||||
async reject(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string) {
|
||||
return this.expenseService.reject(user.tenantId, id, user.sub);
|
||||
}
|
||||
|
||||
@Get('recurring')
|
||||
async getRecurring(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.expenseService.getRecurring(user.tenantId);
|
||||
}
|
||||
|
||||
@Post('recurring')
|
||||
async createRecurring(@CurrentUser() user: CurrentUserPayload, @Body() dto: CreateRecurringDto) {
|
||||
return this.expenseService.createRecurring(user.tenantId, dto);
|
||||
}
|
||||
|
||||
@Patch('recurring/:id/toggle')
|
||||
async toggleRecurring(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string) {
|
||||
return this.expenseService.toggleRecurring(user.tenantId, id);
|
||||
}
|
||||
|
||||
@Delete('recurring/:id')
|
||||
async deleteRecurring(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string) {
|
||||
return this.expenseService.deleteRecurring(user.tenantId, id);
|
||||
}
|
||||
}
|
||||
13
src/expense/expense.module.ts
Normal file
13
src/expense/expense.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ExpenseController } from './expense.controller';
|
||||
import { ExpenseService } from './expense.service';
|
||||
import { AccountingModule } from '../accounting/accounting.module';
|
||||
import { NotificationModule } from '../notification/notification.module';
|
||||
|
||||
@Module({
|
||||
imports: [AccountingModule, NotificationModule],
|
||||
controllers: [ExpenseController],
|
||||
providers: [ExpenseService],
|
||||
exports: [ExpenseService],
|
||||
})
|
||||
export class ExpenseModule {}
|
||||
129
src/expense/expense.service.ts
Normal file
129
src/expense/expense.service.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { JournalService } from '../accounting/journal.service';
|
||||
import { NotificationService } from '../notification/notification.service';
|
||||
import { CreateExpenseDto } from './dto/create-expense.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ExpenseService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly journal: JournalService,
|
||||
private readonly notificationService: NotificationService,
|
||||
) {}
|
||||
|
||||
async findAll(tenantId: string, filters?: { status?: string; category?: string }) {
|
||||
return this.prisma.expense.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
...(filters?.status && { status: filters.status }),
|
||||
...(filters?.category && { category: filters.category }),
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(tenantId: string, createdById: string, dto: CreateExpenseDto) {
|
||||
return this.prisma.expense.create({
|
||||
data: {
|
||||
tenantId,
|
||||
createdById,
|
||||
category: dto.category,
|
||||
description: dto.description,
|
||||
amount: dto.amount,
|
||||
expenseDate: dto.expenseDate ? new Date(dto.expenseDate) : new Date(),
|
||||
notes: dto.notes,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async approve(tenantId: string, id: string, approvedById: string) {
|
||||
const expense = await this.prisma.expense.findFirst({ where: { id, tenantId } });
|
||||
if (!expense) throw new NotFoundException('Expense not found');
|
||||
if (expense.status !== 'pending') throw new BadRequestException(`Expense is already ${expense.status}`);
|
||||
|
||||
const activeUserCount = await this.prisma.user.count({ where: { tenantId, isActive: true } });
|
||||
if (activeUserCount > 1 && expense.createdById === approvedById) {
|
||||
throw new ForbiddenException('Cannot approve your own expense');
|
||||
}
|
||||
|
||||
const result = await this.prisma.expense.update({
|
||||
where: { id },
|
||||
data: { status: 'approved', approvedById, approvedAt: new Date() },
|
||||
});
|
||||
|
||||
// Journal entry: DR Expense Category, CR Cash on Hand
|
||||
this.journal.journalForExpense(tenantId, id, Number(expense.amount), expense.category).catch(() => {});
|
||||
|
||||
// Decrement Cash on Hand CompanyAccount
|
||||
this.journal.updateCompanyAccountBalance(tenantId, '1010', Number(expense.amount), 'decrement')
|
||||
.catch(() => {});
|
||||
|
||||
// Notify about expense approval
|
||||
this.notificationService.create(tenantId, {
|
||||
type: 'in_app',
|
||||
channel: 'billing_reminder',
|
||||
title: 'Expense Approved',
|
||||
message: `₱${Number(expense.amount).toLocaleString()} expense for "${expense.description}" has been approved`,
|
||||
}).catch(() => {});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async reject(tenantId: string, id: string, rejectedById: string) {
|
||||
const expense = await this.prisma.expense.findFirst({ where: { id, tenantId } });
|
||||
if (!expense) throw new NotFoundException('Expense not found');
|
||||
|
||||
const activeUserCount = await this.prisma.user.count({ where: { tenantId, isActive: true } });
|
||||
if (activeUserCount > 1 && expense.createdById === rejectedById) {
|
||||
throw new ForbiddenException('Cannot reject your own expense');
|
||||
}
|
||||
|
||||
return this.prisma.expense.update({
|
||||
where: { id },
|
||||
data: { status: 'rejected', approvedById: rejectedById, approvedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async getSummary(tenantId: string) {
|
||||
const [pending, approved, byCategory] = await Promise.all([
|
||||
this.prisma.expense.aggregate({ where: { tenantId, status: 'pending' }, _sum: { amount: true }, _count: true }),
|
||||
this.prisma.expense.aggregate({ where: { tenantId, status: 'approved' }, _sum: { amount: true }, _count: true }),
|
||||
this.prisma.expense.groupBy({ by: ['category'], where: { tenantId, status: 'approved' }, _sum: { amount: true } }),
|
||||
]);
|
||||
return {
|
||||
pending: { total: Number(pending._sum.amount || 0), count: pending._count },
|
||||
approved: { total: Number(approved._sum.amount || 0), count: approved._count },
|
||||
byCategory: byCategory.map((c) => ({ category: c.category, total: Number(c._sum.amount || 0) })),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Recurring Expenses ──────────────────────────────────
|
||||
|
||||
async getRecurring(tenantId: string) {
|
||||
return this.prisma.recurringExpense.findMany({ where: { tenantId }, orderBy: { nextRunDate: 'asc' } });
|
||||
}
|
||||
|
||||
async createRecurring(tenantId: string, data: { category: string; description: string; amount: number; frequency?: string }) {
|
||||
const nextRunDate = new Date();
|
||||
nextRunDate.setMonth(nextRunDate.getMonth() + 1);
|
||||
nextRunDate.setDate(1);
|
||||
|
||||
return this.prisma.recurringExpense.create({
|
||||
data: { tenantId, category: data.category, description: data.description, amount: data.amount, frequency: data.frequency || 'monthly', nextRunDate },
|
||||
});
|
||||
}
|
||||
|
||||
async toggleRecurring(tenantId: string, id: string) {
|
||||
const rec = await this.prisma.recurringExpense.findFirst({ where: { id, tenantId } });
|
||||
if (!rec) throw new NotFoundException('Not found');
|
||||
return this.prisma.recurringExpense.update({ where: { id }, data: { isActive: !rec.isActive } });
|
||||
}
|
||||
|
||||
async deleteRecurring(tenantId: string, id: string) {
|
||||
const rec = await this.prisma.recurringExpense.findFirst({ where: { id, tenantId } });
|
||||
if (!rec) throw new NotFoundException('Not found');
|
||||
await this.prisma.recurringExpense.delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user