initial: standalone repo from monorepo split

This commit is contained in:
kevin-asprec
2026-04-13 09:36:55 +08:00
commit 4a8f1e9318
157 changed files with 9198 additions and 0 deletions

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

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

View 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 {}

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