diff --git a/src/common/enums/permission.enum.ts b/src/common/enums/permission.enum.ts index e561f5a..c94982e 100644 --- a/src/common/enums/permission.enum.ts +++ b/src/common/enums/permission.enum.ts @@ -7,7 +7,13 @@ export enum PermissionEnum { VIEW_REQUESTS = 'view_requests', DELETE_REQUEST = 'delete_request', UPDATE_REQUEST = 'update_request', - + + // invoice + VIEW_INVOICES = 'view_invoices', + CREATE_INVOICE = 'create_invoice', + UPDATE_INVOICE = 'update_invoice', + DELETE_INVOICE = 'delete_invoice', + VIEW_All_INVOICED_ORDERS = 'view_all_invoiced_orders', VIEW_MY_ASSIGNED_INVOICED_REQUESTS = 'view_my_assigned_invoiced_requests', @@ -63,5 +69,9 @@ export const PermissionTitles: Record = { [PermissionEnum.VIEW_MY_ASSIGNED_INVOICED_REQUESTS]: "مشاهده همه پیش فاکتور های من", [PermissionEnum.CREATE_ORDER_PRINT]: "ایجاد سفارش چاپ", [PermissionEnum.DELETE_REQUEST]: "", - [PermissionEnum.UPDATE_REQUEST]: "" + [PermissionEnum.UPDATE_REQUEST]: "", + [PermissionEnum.VIEW_INVOICES]: "", + [PermissionEnum.CREATE_INVOICE]: "", + [PermissionEnum.UPDATE_INVOICE]: "", + [PermissionEnum.DELETE_INVOICE]: "" }; diff --git a/src/modules/invoice/dto/create-invoice.dto.ts b/src/modules/invoice/dto/create-invoice.dto.ts index 22d9920..703b32f 100644 --- a/src/modules/invoice/dto/create-invoice.dto.ts +++ b/src/modules/invoice/dto/create-invoice.dto.ts @@ -1 +1,62 @@ -export class CreateInvoiceDto {} +import { + IsString, + IsInt, + IsArray, + IsNumber, + IsOptional, + IsBoolean, + IsDateString, + ArrayMinSize, + Min, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +export class CreateInvoiceItemDto { + @IsString() + @ApiProperty({ description: 'Product ID' }) + productId!: string; + + @IsInt() + @Min(1) + @ApiProperty({ example: 1 }) + quantity!: number; + + @IsNumber() + @Min(0) + @ApiProperty({ example: 10000 }) + unitPrice: number; + + @IsOptional() + @IsNumber() + @Min(0) + @ApiPropertyOptional({ example: 0 }) + discount?: number; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + description?: string; +} + +export class CreateInvoiceDto { + @IsString() + @ApiProperty({ description: 'Request ID to create invoice for' }) + requestId!: string; + + @IsArray() + @ArrayMinSize(1) + @Type(() => CreateInvoiceItemDto) + @ApiProperty({ type: [CreateInvoiceItemDto] }) + items!: CreateInvoiceItemDto[]; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional({ example: false }) + enableTax?: boolean; + + @IsOptional() + @IsDateString() + @ApiPropertyOptional({ description: 'Approval deadline (ISO date string)' }) + approvalDeadline?: string; +} diff --git a/src/modules/invoice/dto/find-invoices.dto.ts b/src/modules/invoice/dto/find-invoices.dto.ts new file mode 100644 index 0000000..51bbafb --- /dev/null +++ b/src/modules/invoice/dto/find-invoices.dto.ts @@ -0,0 +1,74 @@ +import { + IsOptional, + IsString, + IsNumber, + Min, + Max, + IsIn, + IsDateString, + IsBoolean, +} from 'class-validator'; +import { Type, Transform } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +const sortOrderOptions = ['asc', 'desc'] as const; +type SortOrder = (typeof sortOrderOptions)[number]; + +export class FindInvoicesDto { + @ApiPropertyOptional({ default: 1, minimum: 1 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + page: number = 1; + + @ApiPropertyOptional({ default: 10, minimum: 1, maximum: 50 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + @Max(50) + limit: number = 10; + + @ApiPropertyOptional({ + description: 'Search by invoice number or user info', + }) + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ format: 'date-time' }) + @IsOptional() + @IsDateString() + from?: string; + + @ApiPropertyOptional({ format: 'date-time' }) + @IsOptional() + @IsDateString() + to?: string; + + @ApiPropertyOptional({ + description: 'Filter by enableTax', + }) + @IsOptional() + @Transform(({ value }) => { + if (value === 'true') return true; + if (value === 'false') return false; + return value; + }) + @IsBoolean() + enableTax?: boolean; + + @ApiPropertyOptional({ default: 'createdAt' }) + @IsOptional() + @IsString() + orderBy: string = 'createdAt'; + + @ApiPropertyOptional({ + enum: sortOrderOptions, + default: 'desc', + }) + @IsOptional() + @IsIn(sortOrderOptions) + order: SortOrder = 'desc'; +} diff --git a/src/modules/invoice/entities/invoice-item.entity.ts b/src/modules/invoice/entities/invoice-item.entity.ts index cd6dced..377d409 100644 --- a/src/modules/invoice/entities/invoice-item.entity.ts +++ b/src/modules/invoice/entities/invoice-item.entity.ts @@ -18,8 +18,8 @@ export class InvoiceItem extends BaseEntity { @Property({ type: 'int' }) quantity!: number; - @Property({ type: 'int', nullable: true, }) - unitPrice?: number; + @Property({ type: 'int'}) + unitPrice: number; @Property({ type: 'int', diff --git a/src/modules/invoice/invoice.controller.ts b/src/modules/invoice/invoice.controller.ts index 7d9d0cc..c92edcc 100644 --- a/src/modules/invoice/invoice.controller.ts +++ b/src/modules/invoice/invoice.controller.ts @@ -1,34 +1,73 @@ -import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common'; +import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Query } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation, ApiBody } from '@nestjs/swagger'; import { InvoiceService } from './invoice.service'; import { CreateInvoiceDto } from './dto/create-invoice.dto'; import { UpdateInvoiceDto } from './dto/update-invoice.dto'; +import { AdminAuthGuard } from '../auth/guards/adminAuth.guard'; +import { Permissions } from 'src/common/decorators/permissions.decorator'; +import { PermissionEnum } from 'src/common/enums/permission.enum'; +import { AuthGuard } from '../auth/guards/auth.guard'; +import { FindInvoicesDto } from './dto/find-invoices.dto'; +import { UserId } from 'src/common/decorators'; -@Controller('invoice') +@Controller() +@ApiTags('invoice') +@ApiBearerAuth() export class InvoiceController { - constructor(private readonly invoiceService: InvoiceService) {} + constructor(private readonly invoiceService: InvoiceService) { } - @Post() - create(@Body() createInvoiceDto: CreateInvoiceDto) { - return this.invoiceService.create(createInvoiceDto); + @Get('public/invoice') + @UseGuards(AuthGuard) + @ApiOperation({ summary: 'Get all invoices with pagination and filters' }) + findAll(@Query() dto: FindInvoicesDto, @UserId() userId: string) { + return this.invoiceService.findAll(userId, dto); } - @Get() - findAll() { - return this.invoiceService.findAll(); + @Get('public/invoice/:id') + @UseGuards(AuthGuard) + @ApiOperation({ summary: 'Get one invoice by id' }) + findOne(@Param('id') id: string,@UserId() userId: string) { + return this.invoiceService.findOne(id,userId); } - @Get(':id') - findOne(@Param('id') id: string) { - return this.invoiceService.findOne(+id); + @Get('public/invoice/item/:id') + @UseGuards(AuthGuard) + @ApiOperation({ summary: 'Get one invoice item by id' }) + confirmInvoiceItem(@Param('id') id: string,@UserId() userId: string) { + return this.invoiceService.confirmInvoiceItem(id,userId); } - @Patch(':id') + //------------------- Admin Routes------------- + @Post('admin/invoice') + @UseGuards(AdminAuthGuard) + @Permissions(PermissionEnum.CREATE_INVOICE) + @ApiOperation({ summary: 'Create a new invoice (admin)' }) + @ApiBody({ type: CreateInvoiceDto }) + create(@Body() dto: CreateInvoiceDto) { + return this.invoiceService.create(dto); + } + + @Get('admin/invoice') + @UseGuards(AuthGuard) + @ApiOperation({ summary: 'Get all invoices with pagination and filters' }) + findAllAsAdmin(@Query() dto: FindInvoicesDto) { + return this.invoiceService.findAllAsAdmin(dto); + } + + @Patch('admin/invoice/:id') + @UseGuards(AdminAuthGuard) + @Permissions(PermissionEnum.UPDATE_INVOICE) + @ApiOperation({ summary: 'Update an invoice (admin)' }) + @ApiBody({ type: UpdateInvoiceDto }) update(@Param('id') id: string, @Body() updateInvoiceDto: UpdateInvoiceDto) { - return this.invoiceService.update(+id, updateInvoiceDto); + return this.invoiceService.update(id, updateInvoiceDto); } - @Delete(':id') + @Delete('admin/invoice/:id') + @UseGuards(AdminAuthGuard) + @Permissions(PermissionEnum.DELETE_INVOICE) + @ApiOperation({ summary: 'Remove an invoice (admin)' }) remove(@Param('id') id: string) { - return this.invoiceService.remove(+id); + return this.invoiceService.remove(id); } } diff --git a/src/modules/invoice/invoice.module.ts b/src/modules/invoice/invoice.module.ts index 94ff907..0ea6f6a 100644 --- a/src/modules/invoice/invoice.module.ts +++ b/src/modules/invoice/invoice.module.ts @@ -1,9 +1,18 @@ import { Module } from '@nestjs/common'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; import { InvoiceService } from './invoice.service'; import { InvoiceController } from './invoice.controller'; +import { Invoice } from './entities/invoice.entity'; +import { InvoiceItem } from './entities/invoice-item.entity'; +import { ProductModule } from '../product/product.module'; +import { InvoiceRepository } from './repositories/invoice.repository'; @Module({ + imports: [ + MikroOrmModule.forFeature([Invoice, InvoiceItem]), + ProductModule, + ], controllers: [InvoiceController], - providers: [InvoiceService], + providers: [InvoiceService, InvoiceRepository], }) export class InvoiceModule {} diff --git a/src/modules/invoice/invoice.service.ts b/src/modules/invoice/invoice.service.ts index a28a234..30808a8 100644 --- a/src/modules/invoice/invoice.service.ts +++ b/src/modules/invoice/invoice.service.ts @@ -1,26 +1,152 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { EntityManager } from '@mikro-orm/postgresql'; import { CreateInvoiceDto } from './dto/create-invoice.dto'; import { UpdateInvoiceDto } from './dto/update-invoice.dto'; +import { FindInvoicesDto } from './dto/find-invoices.dto'; +import { Invoice } from './entities/invoice.entity'; +import { InvoiceItem } from './entities/invoice-item.entity'; +import { Request } from '../request/entities/request.entity'; +import { ProductService } from '../product/providers/product.service'; +import { InvoiceRepository } from './repositories/invoice.repository'; + +const TAX_RATE = 0.09; @Injectable() export class InvoiceService { - create(createInvoiceDto: CreateInvoiceDto) { - return 'This action adds a new invoice'; + constructor( + private readonly em: EntityManager, + private readonly productService: ProductService, + private readonly invoiceRepository: InvoiceRepository, + ) { } + + + async create(dto: CreateInvoiceDto) { + const request = await this.em.findOne( + Request, + { id: dto.requestId }, + { populate: ['user'] }, + ); + if (!request) { + throw new NotFoundException('Request not found'); + } + + const enableTax = dto.enableTax ?? false; + const approvalDeadline = dto.approvalDeadline + ? new Date(dto.approvalDeadline) + : undefined; + + const discount = dto.items.reduce((acc, item) => acc + (item.discount ?? 0), 0); + + return this.em.transactional(async (em) => { + const invoice = em.create(Invoice, { + user: request.user, + request, + enableTax, + approvalDeadline, + discount, + paidAmount: 0, + balance: 0, + }); + em.persist(invoice); + + let subTotal = 0; + + for (const item of dto.items) { + const product = await this.productService.findOneOrFail(item.productId); + const unitPrice = item.unitPrice; + const itemDiscount = item.discount ?? 0; + const itemTotal = Math.max(0, unitPrice * item.quantity - itemDiscount); + subTotal += itemTotal; + + const invoiceItem = em.create(InvoiceItem, { + invoice, + product, + quantity: item.quantity, + unitPrice: unitPrice, + discount: itemDiscount || undefined, + description: item.description, + }); + invoice.items.add(invoiceItem); + } + + const taxAmount = enableTax + ? Math.round((subTotal - discount) * TAX_RATE) + : 0; + const total = Math.max(0, subTotal - discount + taxAmount); + + invoice.subTotal = subTotal; + invoice.taxAmount = taxAmount; + invoice.total = total; + invoice.balance = total - invoice.paidAmount; + + await em.flush(); + return invoice; + }); } - findAll() { - return `This action returns all invoice`; + findAll(userId: string, dto: FindInvoicesDto) { + return this.invoiceRepository.findAllPaginated({ userId, ...dto }); } - findOne(id: number) { - return `This action returns a #${id} invoice`; + findAllAsAdmin(dto: FindInvoicesDto) { + return this.invoiceRepository.findAllPaginated(dto); } - update(id: number, updateInvoiceDto: UpdateInvoiceDto) { - return `This action updates a #${id} invoice`; + async findOne(id: string, userId: string) { + const invoice = await this.em.findOne( + Invoice, + { id, user: { id: userId } }, + { populate: ['user', 'request', 'items', 'items.product'] }, + ); + if (!invoice) { + throw new NotFoundException('Invoice not found'); + } + return invoice; } - remove(id: number) { - return `This action removes a #${id} invoice`; + async confirmInvoiceItem(id: string, userId: string) { + const item = await this.em.findOne( + InvoiceItem, + { id, invoice: { user: { id: userId } } }, + { populate: ['invoice', 'product'] }, + ); + if (!item) { + throw new NotFoundException('Invoice item not found'); + } + if(item.confirmedAt){ + throw new BadRequestException('Invoice item already confirmed'); + } + item.confirmedAt = new Date(); + await this.em.persistAndFlush(item); + return item; + } + + async update(id: string, updateInvoiceDto: UpdateInvoiceDto) { + const invoice = await this.findOneOrFail(id); + if (updateInvoiceDto.enableTax !== undefined) invoice.enableTax = updateInvoiceDto.enableTax; + if (updateInvoiceDto.approvalDeadline !== undefined) { + invoice.approvalDeadline = new Date(updateInvoiceDto.approvalDeadline); + } + await this.em.flush(); + return invoice; + } + + async remove(id: string) { + const invoice = await this.findOneOrFail(id); + this.em.remove(invoice); + await this.em.flush(); + return invoice; + } + + async findOneOrFail(id: string) { + const invoice = await this.em.findOne( + Invoice, + { id }, + { populate: ['user', 'request', 'items', 'items.product'] }, + ); + if (!invoice) { + throw new NotFoundException('Invoice not found'); + } + return invoice; } } diff --git a/src/modules/invoice/repositories/invoice.repository.ts b/src/modules/invoice/repositories/invoice.repository.ts new file mode 100644 index 0000000..8d0e5d6 --- /dev/null +++ b/src/modules/invoice/repositories/invoice.repository.ts @@ -0,0 +1,88 @@ +import { Injectable } from '@nestjs/common'; +import { EntityManager, EntityRepository } from '@mikro-orm/postgresql'; +import { FilterQuery } from '@mikro-orm/core'; +import { Invoice } from '../entities/invoice.entity'; +import { PaginatedResult } from 'src/common/interfaces/pagination.interface'; +import { FindInvoicesDto } from '../dto/find-invoices.dto'; + +interface FindInvoicesOpts extends FindInvoicesDto { + userId?: string; +} + +@Injectable() +export class InvoiceRepository extends EntityRepository { + constructor(readonly em: EntityManager) { + super(em, Invoice); + } + + async findAllPaginated(opts: FindInvoicesOpts): Promise> { + const { + page = 1, + limit = 10, + search, + orderBy = 'createdAt', + order = 'desc', + userId, + from, + to, + enableTax, + } = opts; + + const offset = (page - 1) * limit; + + const where: FilterQuery = {}; + + if (userId) { + where.user = { id: userId }; + } + + if (typeof enableTax === 'boolean') { + where.enableTax = enableTax; + } + + if (from || to) { + where.createdAt = {}; + if (from) { + where.createdAt.$gte = new Date(from); + } + if (to) { + where.createdAt.$lte = new Date(to); + } + } + + if (search) { + const searchPattern = `%${search}%`; + const searchConditions: FilterQuery[] = [ + { user: { firstName: { $ilike: searchPattern } } } as FilterQuery, + { user: { lastName: { $ilike: searchPattern } } } as FilterQuery, + { user: { phone: { $ilike: searchPattern } } } as FilterQuery, + ]; + + const numericSearch = Number(search); + if (!isNaN(numericSearch) && isFinite(numericSearch)) { + searchConditions.push({ invoiceNumber: numericSearch } as FilterQuery); + } + + where.$or = searchConditions; + } + + const [data, total] = await this.findAndCount(where, { + limit, + offset, + orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' }, + populate: ['user', 'request', 'items', 'items.product'], + }); + + const totalPages = Math.ceil(total / limit); + + return { + data, + meta: { + total, + page, + limit, + totalPages, + }, + }; + } +} diff --git a/src/modules/request/request.controller.ts b/src/modules/request/request.controller.ts index ba8d141..2b407a0 100644 --- a/src/modules/request/request.controller.ts +++ b/src/modules/request/request.controller.ts @@ -1,4 +1,5 @@ import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Query } from '@nestjs/common'; +import { ApiTags,ApiBearerAuth } from '@nestjs/swagger'; import { RequestService } from './request.service'; import { CreateRequestDto } from './dto/create-request.dto'; import { UpdateRequestDto } from './dto/update-request.dto'; @@ -10,6 +11,8 @@ import { Permissions } from 'src/common/decorators/permissions.decorator'; import { PermissionEnum } from 'src/common/enums/permission.enum'; @Controller() +@ApiTags('request') +@ApiBearerAuth() export class RequestController { constructor(private readonly requestService: RequestService) { }