diff --git a/src/app.module.ts b/src/app.module.ts index 0945925..1d29b0d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -17,6 +17,7 @@ import { HTTPLogger } from "./core/middlewares/logger.middleware"; import { AuthModule } from "./modules/auth/auth.module"; import { CompaniesModule } from "./modules/companies/companies.module"; import { IndustriesModule } from "./modules/industries/industries.module"; +import { InvoicesModule } from "./modules/invoices/invoices.module"; import { NotificationModule } from "./modules/notifications/notifications.module"; import { TicketsModule } from "./modules/tickets/tickets.module"; import { UploaderModule } from "./modules/uploader/uploader.module"; @@ -48,6 +49,7 @@ import { UtilsModule } from "./modules/utils/utils.module"; CompaniesModule, UploaderModule, TicketsModule, + InvoicesModule, ], }) export class AppModule implements NestModule { diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index fdff98e..1e86b25 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -594,4 +594,6 @@ export const enum CompanyMessage { SERVICES_REQUIRED = "خدمات الزامی است", PRODUCTS_MUST_BE_ARRAY = "محصولات باید آرایه باشد", SERVICES_MUST_BE_ARRAY = "خدمات باید آرایه باشد", + COMPANY_ID_REQUIRED = "شرکت مورد نیاز است", + COMPANY_ID_SHOULD_BE_A_UUID = "شناسه شرکت باید یک UUID معتبر باشد", } diff --git a/src/configs/mikro-orm.config.ts b/src/configs/mikro-orm.config.ts index faef041..f0d692d 100755 --- a/src/configs/mikro-orm.config.ts +++ b/src/configs/mikro-orm.config.ts @@ -13,12 +13,14 @@ export const databaseConfig: MikroOrmModuleAsyncOptions = { const DB_USER = configService.getOrThrow("DB_USER"); const DB_HOST = configService.getOrThrow("DB_HOST"); const DB_PORT = configService.getOrThrow("DB_PORT"); + const encodedPassword = encodeURIComponent(DB_PASS); + return { driver: PostgreSqlDriver, autoLoadEntities: true, dbName: configService.getOrThrow("DB_NAME"), debug: configService.get("NODE_ENV") !== "production", - clientUrl: `postgres://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}`, + clientUrl: `postgres://${DB_USER}:${encodedPassword}@${DB_HOST}:${DB_PORT}`, ensureDatabase: { forceCheck: true, create: true, schema: "update" }, forceUtcTimezone: true, pool: { diff --git a/src/modules/companies/entities/company.entity.ts b/src/modules/companies/entities/company.entity.ts index 99e3000..87d72b1 100644 --- a/src/modules/companies/entities/company.entity.ts +++ b/src/modules/companies/entities/company.entity.ts @@ -4,6 +4,7 @@ import { CompanyProduct } from "./company-product.entity"; import { CompanyService } from "./company-service.entity"; import { BaseEntity } from "../../../common/entities/base.entity"; import { Industry } from "../../industries/entities/industry.entity"; +import { Invoice } from "../../invoices/entities/invoice.entity"; import { User } from "../../users/entities/user.entity"; import { CompanyStatus } from "../enums/company-status.enum"; import { CompanyRepository } from "../repositories/company.repository"; @@ -51,6 +52,9 @@ export class Company extends BaseEntity { @OneToMany(() => CompanyService, (service) => service.company, { cascade: [Cascade.ALL] }) services = new Collection(this); + @OneToMany(() => Invoice, (invoice) => invoice.company) + invoices = new Collection(this); + //----------------------------------- @ManyToOne(() => Industry, { deleteRule: "restrict" }) diff --git a/src/modules/companies/repositories/company.repository.ts b/src/modules/companies/repositories/company.repository.ts index 29bf696..314e514 100644 --- a/src/modules/companies/repositories/company.repository.ts +++ b/src/modules/companies/repositories/company.repository.ts @@ -1,4 +1,4 @@ -import { EntityRepository } from "@mikro-orm/core"; +import { EntityRepository } from "@mikro-orm/postgresql"; import { PaginationUtils } from "../../utils/providers/pagination.utils"; import { CompanyListQueryDto } from "../DTO/company-list-query.dto"; @@ -9,17 +9,57 @@ export class CompanyRepository extends EntityRepository { async getCompaniesListForAdmin(queryDto: CompanyListQueryDto) { const { limit, skip } = PaginationUtils(queryDto); - return await this.findAndCount( - { + const qb = this.createQueryBuilder("company") + .select([ + "company.id", + "company.name", + "company.status", + "company.isActive", + "company.createdAt", + "industry.id", + "industry.title", + "user.id", + "user.firstName", + "user.lastName", + "(SELECT COUNT(*) FROM invoice WHERE invoice.company_id = company.id) as invoiceCount", + ]) + .leftJoin("company.industry", "industry") + .leftJoin("company.user", "user") + .where({ deletedAt: null, ...(queryDto.status && { status: queryDto.status }), - ...(queryDto.q && { name: { $ilike: `%${queryDto.q}%` } }), - ...(queryDto.industryId && { industry: { id: queryDto.industryId } }), ...(queryDto.isActive !== undefined && { isActive: queryDto.isActive === 1 }), - ...(queryDto.registrationDate && { createdAt: { $gte: queryDto.registrationDate, $lte: queryDto.registrationDate } }), - ...(queryDto.name && { name: { $ilike: `%${queryDto.name}%` } }), - }, - { offset: skip, limit, orderBy: { createdAt: "DESC" } }, - ); + }); + + if (queryDto.q) { + qb.andWhere({ name: { $ilike: `%${queryDto.q}%` } }); + } + + if (queryDto.industryId) { + qb.andWhere({ industry: { id: queryDto.industryId } }); + } + + if (queryDto.registrationDate) { + qb.andWhere({ + createdAt: { + $gte: queryDto.registrationDate, + $lte: queryDto.registrationDate, + }, + }); + } + + if (queryDto.name) { + qb.andWhere({ name: { $ilike: `%${queryDto.name}%` } }); + } + + qb.orderBy({ createdAt: "DESC" }).limit(limit).offset(skip); + + return qb.getResultAndCount(); + + // // Transform the result to include invoiceCount as a number + // const transformedCompanies = companies.map((company) => ({ + // ...company, + // invoiceCount: parseInt(company.invoiceCount, 10), + // })); } } diff --git a/src/modules/companies/services/companies.service.ts b/src/modules/companies/services/companies.service.ts index eac03b0..ab7c27f 100644 --- a/src/modules/companies/services/companies.service.ts +++ b/src/modules/companies/services/companies.service.ts @@ -179,6 +179,20 @@ export class CompaniesService { }; } //----------------------------------- + + async findOneByIdWithEntityManager(id: string, em: EntityManager) { + const company = await em.findOne(Company, { id, deletedAt: null }, { populate: ["user"] }); + if (!company) throw new BadRequestException(CompanyMessage.COMPANY_NOT_FOUND); + return company; + } + //----------------------------------- + + async findOneByUserIdWithEntityManager(userId: string, em: EntityManager) { + const company = await em.findOne(Company, { user: { id: userId }, deletedAt: null }, { populate: ["user"] }); + if (!company) throw new BadRequestException(CompanyMessage.COMPANY_NOT_FOUND); + return company; + } + //----------------------------------- private async findCompanyById(id: string) { const company = await this.companyRepository.findOne({ id, deletedAt: null }); if (!company) throw new BadRequestException(CompanyMessage.COMPANY_NOT_FOUND); diff --git a/src/modules/invoices/DTO/create-invoice.dto.ts b/src/modules/invoices/DTO/create-invoice.dto.ts new file mode 100755 index 0000000..8189875 --- /dev/null +++ b/src/modules/invoices/DTO/create-invoice.dto.ts @@ -0,0 +1,67 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { ArrayMinSize, IsBoolean, IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Length, ValidateNested } from "class-validator"; + +import { CompanyMessage, InvoiceMessage } from "../../../common/enums/message.enum"; +import { RecurringPeriodEnum } from "../enums/invoice-recurring-period.enum"; + +export class InvoiceItemDto { + @IsNotEmpty({ message: InvoiceMessage.NAME_REQUIRED }) + @IsString({ message: InvoiceMessage.NAME_MUST_BE_A_STRING }) + @Length(1, 150, { message: InvoiceMessage.NAME_LENGTH }) + @ApiProperty({ type: String, description: "Item name" }) + name: string; + + @IsNotEmpty({ message: InvoiceMessage.COUNT_REQUIRED }) + @IsInt({ message: InvoiceMessage.COUNT_MUST_BE_A_INT }) + @ApiProperty({ type: Number, description: "Item count", example: 2 }) + count: number; + + @IsNotEmpty({ message: InvoiceMessage.UNIT_PRICE_REQUIRED }) + @IsInt({ message: InvoiceMessage.UNIT_PRICE_MUST_BE_A_INT }) + @ApiProperty({ description: "Item unit price", example: 100_000 }) + unitPrice: number; + + @IsOptional() + @IsNotEmpty({ message: InvoiceMessage.DISCOUNT_REQUIRED }) + @IsInt({ message: InvoiceMessage.DISCOUNT_MUST_BE_A_INT }) + @ApiProperty({ description: "Item discount", example: 10 }) + discount: number; +} + +export class CreateInvoiceDto { + @IsNotEmpty({ message: CompanyMessage.COMPANY_ID_REQUIRED }) + @IsUUID("7", { message: CompanyMessage.COMPANY_ID_SHOULD_BE_A_UUID }) + @ApiProperty({ description: "Company id", example: "123e4567-e89b-12d3-a456-426614174000" }) + companyId: string; + + @ApiProperty({ + type: [InvoiceItemDto], + description: "Invoice items", + example: [{ name: "item1", count: 2, unitPrice: 100_000, discount: 10 }], + }) + @ValidateNested({ each: true }) + @ArrayMinSize(1) + @Type(() => InvoiceItemDto) + items: InvoiceItemDto[]; + + @IsOptional() + @IsBoolean({ message: InvoiceMessage.IS_RECURRING_MUST_BE_A_BOOLEAN }) + @ApiProperty({ description: "Is this a recurring invoice", example: false, default: false }) + isRecurring?: boolean; + + @IsOptional() + @IsNotEmpty({ message: InvoiceMessage.RECURRING_PERIOD_REQUIRED }) + @IsEnum(RecurringPeriodEnum) + @ApiPropertyOptional({ + description: "Recurring period (daily, weekly, monthly, quarterly, yearly)", + example: RecurringPeriodEnum.QUARTERLY, + enum: RecurringPeriodEnum, + }) + recurringPeriod?: RecurringPeriodEnum; + + @IsOptional() + @IsInt({ message: InvoiceMessage.MAX_RECURRING_CYCLES_MUST_BE_POSITIVE }) + @ApiPropertyOptional({ description: "Maximum number of recurring cycles (0 for unlimited)", example: 12, default: 0 }) + maxRecurringCycles?: number; +} diff --git a/src/modules/invoices/DTO/invoices-search-query.dto.ts b/src/modules/invoices/DTO/invoices-search-query.dto.ts new file mode 100755 index 0000000..6981a85 --- /dev/null +++ b/src/modules/invoices/DTO/invoices-search-query.dto.ts @@ -0,0 +1,35 @@ +import { ApiPropertyOptional, PickType } from "@nestjs/swagger"; +import { IsDateString, IsEnum, IsOptional, IsString, IsUUID } from "class-validator"; + +import { PaginationDto } from "../../../common/DTO/pagination.dto"; +import { CompanyMessage, InvoiceMessage } from "../../../common/enums/message.enum"; +import { InvoiceStatus } from "../enums/invoice-status.enum"; + +export class InvoicesSearchQueryDto extends PaginationDto { + @IsOptional() + @IsString({ message: InvoiceMessage.SEARCH_QUERY_MUST_BE_A_STRING }) + @ApiPropertyOptional({ description: "search query", example: "search query" }) + q?: string; + + @IsOptional() + @IsUUID("7", { message: CompanyMessage.COMPANY_ID_SHOULD_BE_A_UUID }) + @ApiPropertyOptional({ description: "company id", example: "123e4567-e89b-12d3-a456-426614174000" }) + companyId?: string; + + @IsOptional() + @IsEnum(InvoiceStatus) + @ApiPropertyOptional({ enum: InvoiceStatus, description: "invoice status", example: InvoiceStatus.PAID }) + status?: InvoiceStatus; + + @IsOptional() + @IsDateString({}, { message: InvoiceMessage.INVALID_DATE }) + @ApiPropertyOptional({ description: "since query", example: "2025-01-27" }) + since?: string; + + @IsOptional() + @IsDateString({}, { message: InvoiceMessage.INVALID_DATE }) + @ApiPropertyOptional({ description: "to date query", example: "2025-01-27" }) + to?: string; +} + +export class UserInvoicesSearchQueryDto extends PickType(InvoicesSearchQueryDto, ["status", "limit", "page"]) {} diff --git a/src/modules/invoices/DTO/update-invoice.dto.ts b/src/modules/invoices/DTO/update-invoice.dto.ts new file mode 100755 index 0000000..7e0dc38 --- /dev/null +++ b/src/modules/invoices/DTO/update-invoice.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from "@nestjs/swagger"; + +import { CreateInvoiceDto } from "./create-invoice.dto"; + +export class UpdateInvoiceDto extends PartialType(CreateInvoiceDto) {} diff --git a/src/modules/invoices/constants/index.ts b/src/modules/invoices/constants/index.ts new file mode 100755 index 0000000..0888d3b --- /dev/null +++ b/src/modules/invoices/constants/index.ts @@ -0,0 +1,31 @@ +export const INVOICE = Object.freeze({ + QUEUE_NAME: "invoice", + REMINDER_JOB_NAME: "reminderInvoice", + RECURRING_JOB_NAME: "recurringInvoice", + SUBSCRIPTION_ADMIN_NOTIFICATION_JOB_NAME: "subscriptionAdminNotification", + // + REMINDER_JOB_PRIORITY: 1, // high priority + REMINDER_JOB_ATTEMPTS: 3, // 3 times + REMINDER_JOB_BACKOFF: 5 * 1000, // ms + REMINDER_JOB_TIMEOUT: 10000, // timeout after 10 seconds + REMINDER_REPEAT_DELAY: 1 * 24 * 60 * 60 * 1000, // 1 day delay in milliseconds + // + + RECURRING_JOB_PRIORITY: 1, // high priority + RECURRING_JOB_ATTEMPTS: 3, // retry 3 times + RECURRING_JOB_BACKOFF: 5 * 1000, // retry after 5 second + RECURRING_JOB_TIMEOUT: 10000, // timeout after 10 seconds + // + + SUBSCRIPTION_ADMIN_NOTIFICATION_JOB_DELAY: 1000, // delay after 1 second + SUBSCRIPTION_ADMIN_NOTIFICATION_JOB_PRIORITY: 1, // high priority + SUBSCRIPTION_ADMIN_NOTIFICATION_JOB_ATTEMPTS: 3, // retry 3 times + SUBSCRIPTION_ADMIN_NOTIFICATION_JOB_BACKOFF: 5 * 1000, // retry after 5 second + SUBSCRIPTION_ADMIN_NOTIFICATION_JOB_TIMEOUT: 10000, // timeout after 10 seconds + // + + FINE_PERCENTAGE: 0.01, // 1% of the total price + MAX_DAYS_AFTER_OVERDUE: 10, + DUEDATE: 7, + DAYS_BEFORE_OVERDUE: 3, +}); diff --git a/src/modules/invoices/entities/invoice-item.entity.ts b/src/modules/invoices/entities/invoice-item.entity.ts new file mode 100755 index 0000000..13190bb --- /dev/null +++ b/src/modules/invoices/entities/invoice-item.entity.ts @@ -0,0 +1,29 @@ +import { DecimalType, Entity, EntityRepositoryType, ManyToOne, Property } from "@mikro-orm/postgresql"; +import Decimal from "decimal.js"; + +import { Invoice } from "./invoice.entity"; +import { BaseEntity } from "../../../common/entities/base.entity"; +import { InvoiceItemsRepository } from "../repositories/invoice-items.repository"; + +@Entity({ repository: () => InvoiceItemsRepository }) +export class InvoiceItem extends BaseEntity { + @ManyToOne(() => Invoice, { deleteRule: "cascade" }) + invoice!: Invoice; + + @Property({ type: "varchar", length: 150 }) + name!: string; + + @Property({ type: "int" }) + count!: number; + + @Property({ type: DecimalType, precision: 16, scale: 2 }) + discount!: Decimal; + + @Property({ type: DecimalType, precision: 16, scale: 2 }) + unitPrice!: Decimal; + + @Property({ type: DecimalType, precision: 16, scale: 2 }) + totalPrice!: Decimal; + + [EntityRepositoryType]?: InvoiceItemsRepository; +} diff --git a/src/modules/invoices/entities/invoice.entity.ts b/src/modules/invoices/entities/invoice.entity.ts new file mode 100755 index 0000000..5218858 --- /dev/null +++ b/src/modules/invoices/entities/invoice.entity.ts @@ -0,0 +1,56 @@ +import { Cascade, Collection, Entity, EntityRepositoryType, Enum, ManyToOne, OneToMany, Opt, Property } from "@mikro-orm/core"; +import { Decimal } from "decimal.js"; + +import { InvoiceItem } from "./invoice-item.entity"; +import { BaseEntity } from "../../../common/entities/base.entity"; +import { Company } from "../../companies/entities/company.entity"; +import { RecurringPeriodEnum } from "../enums/invoice-recurring-period.enum"; +import { InvoiceStatus } from "../enums/invoice-status.enum"; +import { InvoicesRepository } from "../repositories/invoices.repository"; + +@Entity({ repository: () => InvoicesRepository }) +export class Invoice extends BaseEntity { + @Property({ type: "int", autoincrement: true, persist: false }) + numericId!: number & Opt; + + @ManyToOne(() => Company, { nullable: false, deleteRule: "cascade" }) + company!: Company; + + @Property({ type: () => Decimal, columnType: "numeric", precision: 16, scale: 2, nullable: false }) + totalPrice!: Decimal; + + @Property({ type: () => Decimal, columnType: "numeric", precision: 16, scale: 2, nullable: false }) + originalPrice!: Decimal; + + @Enum({ items: () => InvoiceStatus, nativeEnumName: "invoice_status", default: InvoiceStatus.PENDING }) + status: InvoiceStatus & Opt; + + @Property({ type: "timestamptz", nullable: false }) + dueDate!: Date; + + @Property({ type: "timestamptz", nullable: true }) + paidAt?: Date; + + @Property({ type: () => Decimal, columnType: "numeric", precision: 16, scale: 2, nullable: false }) + tax!: Decimal; + + @Property({ type: () => Decimal, columnType: "numeric", precision: 16, scale: 2, nullable: true }) + lateFee?: Decimal; + + @Property({ type: "boolean", default: false }) + isRecurring: boolean & Opt; + + @Enum({ items: () => RecurringPeriodEnum, nativeEnumName: "recurring_period", nullable: true }) + recurringPeriod?: RecurringPeriodEnum; + + @Property({ type: "int", default: 0 }) + maxRecurringCycles: number & Opt; + + @Property({ type: "int", default: 0 }) + currentRecurringCycle: number & Opt; + + @OneToMany(() => InvoiceItem, (item) => item.invoice, { cascade: [Cascade.PERSIST, Cascade.REMOVE] }) + items = new Collection(this); + + [EntityRepositoryType]?: InvoicesRepository; +} diff --git a/src/modules/invoices/enums/invoice-recurring-period.enum.ts b/src/modules/invoices/enums/invoice-recurring-period.enum.ts new file mode 100755 index 0000000..40594e0 --- /dev/null +++ b/src/modules/invoices/enums/invoice-recurring-period.enum.ts @@ -0,0 +1,7 @@ +export enum RecurringPeriodEnum { + WEEKLY = 1, + MONTHLY, + QUARTERLY, + BIANNUALLY, + ANNUALLY, +} diff --git a/src/modules/invoices/enums/invoice-status.enum.ts b/src/modules/invoices/enums/invoice-status.enum.ts new file mode 100755 index 0000000..8464722 --- /dev/null +++ b/src/modules/invoices/enums/invoice-status.enum.ts @@ -0,0 +1,9 @@ +export enum InvoiceStatus { + DRAFT = "DRAFT", + PENDING = "PENDING", + WAIT_PAYMENT = "WAIT_PAYMENT", + PAID = "PAID", + OVERDUE = "OVERDUE", + EXPIRED = "EXPIRED", + CANCELLED = "CANCELLED", +} diff --git a/src/modules/invoices/invoices.controller.ts b/src/modules/invoices/invoices.controller.ts new file mode 100755 index 0000000..ea02386 --- /dev/null +++ b/src/modules/invoices/invoices.controller.ts @@ -0,0 +1,66 @@ +import { Body, Controller, Get, Param, Patch, Post, Query } from "@nestjs/common"; +import { ApiOperation } from "@nestjs/swagger"; + +import { CreateInvoiceDto } from "./DTO/create-invoice.dto"; +import { InvoicesSearchQueryDto, UserInvoicesSearchQueryDto } from "./DTO/invoices-search-query.dto"; +import { UpdateInvoiceDto } from "./DTO/update-invoice.dto"; +import { InvoicesService } from "./providers/invoices.service"; +import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; +import { UserDec } from "../../common/decorators/user.decorator"; +import { ParamDto } from "../../common/DTO/param.dto"; +import { VerifyOtpWithUserId } from "../auth/DTO/verify-otp.dto"; +import { RoleEnum } from "../users/enums/role.enum"; + +@Controller("invoices") +@AuthGuards() +export class InvoicesController { + constructor(private readonly invoiceService: InvoicesService) {} + + @ApiOperation({ summary: "create an invoice (admin)" }) + @Post() + createInvoice(@Body() createDto: CreateInvoiceDto) { + return this.invoiceService.createInvoiceAdmin(createDto); + } + + @ApiOperation({ summary: "update an invoice (admin)" }) + @Patch(":id") + updateInvoice(@Param() paramDto: ParamDto, @Body() updateDto: UpdateInvoiceDto) { + return this.invoiceService.updateInvoiceAdmin(paramDto.id, updateDto); + } + + @ApiOperation({ summary: "get all invoices (admin)" }) + @Get() + getInvoices(@Query() queryDto: InvoicesSearchQueryDto) { + return this.invoiceService.getInvoices(queryDto); + } + + @ApiOperation({ summary: "get all user invoices" }) + @Get("user") + getUserInvoices(@Query() queryDto: UserInvoicesSearchQueryDto, @UserDec("id") userId: string) { + return this.invoiceService.getUserInvoices(queryDto, userId); + } + + @ApiOperation({ summary: "get single invoice by Id " }) + @Get(":id") + getInvoiceById(@Param() paramDto: ParamDto, @UserDec("role") role: RoleEnum, @UserDec("id") userId: string) { + return this.invoiceService.getInvoiceById(paramDto.id, role, userId); + } + + @ApiOperation({ summary: "approve request invoice by user" }) + @Post(":id/approve/request") + approveRequestInvoiceByUser(@Param() paramDto: ParamDto, @UserDec("id") userId: string) { + return this.invoiceService.approveInvoiceRequest(paramDto.id, userId); + } + + @ApiOperation({ summary: "approve invoice by user" }) + @Patch(":id/approve") + approveInvoiceByUser(@Param() paramDto: ParamDto, @UserDec("id") userId: string, @Body() verifyOtpDto: VerifyOtpWithUserId) { + return this.invoiceService.approveInvoiceByUser(paramDto.id, userId, verifyOtpDto); + } + + // @ApiOperation({ summary: "pay invoice by user" }) + // @Post(":id/pay") + // async payInvoice(@Param() paramDto: ParamDto, @UserDec("id") userId: string) { + // return this.invoiceService.payInvoice(paramDto.id, userId); + // } +} diff --git a/src/modules/invoices/invoices.module.ts b/src/modules/invoices/invoices.module.ts new file mode 100755 index 0000000..b4d0f15 --- /dev/null +++ b/src/modules/invoices/invoices.module.ts @@ -0,0 +1,29 @@ +import { MikroOrmModule } from "@mikro-orm/nestjs"; +import { BullModule } from "@nestjs/bullmq"; +import { Module } from "@nestjs/common"; + +import { INVOICE } from "./constants"; +import { InvoiceItem } from "./entities/invoice-item.entity"; +import { Invoice } from "./entities/invoice.entity"; +import { InvoicesController } from "./invoices.controller"; +import { InvoicesService } from "./providers/invoices.service"; +import { InvoiceProcessor } from "./queue/invoice.processor"; +import { CompaniesModule } from "../companies/companies.module"; +import { NotificationModule } from "../notifications/notifications.module"; +import { UsersModule } from "../users/users.module"; +import { UtilsModule } from "../utils/utils.module"; + +@Module({ + imports: [ + BullModule.registerQueue({ name: INVOICE.QUEUE_NAME }), + MikroOrmModule.forFeature([Invoice, InvoiceItem]), + UsersModule, + UtilsModule, + NotificationModule, + CompaniesModule, + ], + providers: [InvoicesService, InvoiceProcessor], + controllers: [InvoicesController], + exports: [InvoicesService], +}) +export class InvoicesModule {} diff --git a/src/modules/invoices/providers/invoices.service.ts b/src/modules/invoices/providers/invoices.service.ts new file mode 100755 index 0000000..fde01ad --- /dev/null +++ b/src/modules/invoices/providers/invoices.service.ts @@ -0,0 +1,556 @@ +import { EntityManager } from "@mikro-orm/postgresql"; +import { InjectQueue } from "@nestjs/bullmq"; +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; +import { Queue } from "bullmq"; +import dayjs from "dayjs"; +import Decimal from "decimal.js"; + +import { AuthMessage, InvoiceMessage } from "../../../common/enums/message.enum"; +import { VerifyOtpWithUserId } from "../../auth/DTO/verify-otp.dto"; +import { CompaniesService } from "../../companies/services/companies.service"; +import { NotificationQueue } from "../../notifications/queue/notification.queue"; +import { User } from "../../users/entities/user.entity"; +import { RoleEnum } from "../../users/enums/role.enum"; +import { UsersService } from "../../users/services/users.service"; +import { OTPService } from "../../utils/providers/otp.service"; +import { PaginationUtils } from "../../utils/providers/pagination.utils"; +import { SmsService } from "../../utils/providers/sms.service"; +import { INVOICE } from "../constants"; +import { CreateInvoiceDto, InvoiceItemDto } from "../DTO/create-invoice.dto"; +import { InvoicesSearchQueryDto, UserInvoicesSearchQueryDto } from "../DTO/invoices-search-query.dto"; +import { UpdateInvoiceDto } from "../DTO/update-invoice.dto"; +import { InvoiceItem } from "../entities/invoice-item.entity"; +import { Invoice } from "../entities/invoice.entity"; +import { RecurringPeriodEnum } from "../enums/invoice-recurring-period.enum"; +import { InvoiceStatus } from "../enums/invoice-status.enum"; +import { InvoicesRepository } from "../repositories/invoices.repository"; +@Injectable() +export class InvoicesService { + private readonly logger = new Logger(InvoicesService.name); + + constructor( + @InjectQueue(INVOICE.QUEUE_NAME) private readonly invoiceQueue: Queue, + private readonly notificationQueue: NotificationQueue, + private readonly invoiceRepository: InvoicesRepository, + private readonly companiesService: CompaniesService, + private readonly usersService: UsersService, + private readonly otpService: OTPService, + private readonly smsService: SmsService, + private readonly em: EntityManager, + ) {} + + ///********************************** */ + + private validateInvoiceItems(items: InvoiceItemDto[]): void { + if (!items || items.length === 0) throw new BadRequestException(InvoiceMessage.ITEMS_REQUIRED); + + items.forEach((item) => { + if (item.unitPrice <= 0) throw new BadRequestException(InvoiceMessage.UNIT_PRICE_MUST_BE_POSITIVE); + if (item.count <= 0) throw new BadRequestException(InvoiceMessage.COUNT_MUST_BE_POSITIVE); + if (item.discount && (item.discount < 0 || item.discount > 100)) { + throw new BadRequestException(InvoiceMessage.DISCOUNT_MUST_BE_BETWEEN_0_AND_100); + } + }); + } + ///********************************** */ + + private validateRecurringInvoice(createDto: CreateInvoiceDto): void { + if (createDto.isRecurring) { + if (!createDto.recurringPeriod) throw new BadRequestException(InvoiceMessage.RECURRING_PERIOD_REQUIRED); + + if (createDto.maxRecurringCycles && createDto.maxRecurringCycles < 1) { + throw new BadRequestException(InvoiceMessage.MAX_RECURRING_CYCLES_MUST_BE_POSITIVE); + } + } + } + ///********************************** */ + + async createInvoiceAdmin(createDto: CreateInvoiceDto) { + const em = this.em.fork(); + + try { + await em.begin(); + + this.validateInvoiceItems(createDto.items); + + this.validateRecurringInvoice(createDto); + + const invoiceItems = createDto.items.map((item) => { + const invoiceItem = new InvoiceItem(); + invoiceItem.name = item.name; + invoiceItem.count = item.count; + invoiceItem.unitPrice = new Decimal(item.unitPrice); + invoiceItem.discount = new Decimal(item.discount || 0); + invoiceItem.totalPrice = new Decimal(item.unitPrice * item.count - (item.unitPrice * item.count * item.discount) / 100); + return invoiceItem; + }); + + const totalPrice = invoiceItems.reduce((sum, item) => new Decimal(item.totalPrice).add(sum), new Decimal(0)); + const tax = totalPrice.mul(0.1); + + if (totalPrice.lessThanOrEqualTo(0)) throw new BadRequestException(InvoiceMessage.TOTAL_PRICE_MUST_BE_POSITIVE); + + const company = await this.companiesService.findOneByIdWithEntityManager(createDto.companyId, em); + + const dueDate = dayjs().add(INVOICE.DUEDATE, "day").toDate(); + + const invoice = em.create(Invoice, { + company, + totalPrice: totalPrice.add(tax), + originalPrice: totalPrice.add(tax), + items: invoiceItems, + tax: tax, + dueDate, + status: InvoiceStatus.PENDING, + isRecurring: createDto.isRecurring, + recurringPeriod: createDto.recurringPeriod, + maxRecurringCycles: createDto.maxRecurringCycles, + currentRecurringCycle: 0, + }); + + await em.persistAndFlush(invoice); + + await this.notificationQueue.addInvoiceCreationNotification(company.user.id, { + invoiceId: invoice.numericId.toString(), + dueDate: invoice.dueDate, + createDate: invoice.createdAt, + price: new Decimal(invoice.totalPrice).toNumber(), + userPhone: company.user.phone, + userEmail: company.user.email, + items: invoiceItems.map((item) => item.name).join(", "), + paidAt: invoice.paidAt, + }); + + await this.scheduleInvoiceJobs(invoice, createDto); + await em.commit(); + + return { + message: InvoiceMessage.CREATED, + invoice, + }; + } catch (error) { + await em.rollback(); + this.logger.error( + `Failed to create invoice: ${error instanceof Error ? error.message : "Unknown error"}`, + error instanceof Error ? error.stack : "", + ); + throw error; + } + } + + //********************************** */ + + async updateInvoiceAdmin(invoiceId: string, updateDto: UpdateInvoiceDto) { + const em = this.em.fork(); + + try { + await em.begin(); + + const invoice = await em.findOne(Invoice, { id: invoiceId }, { populate: ["items"] }); + + if (!invoice) throw new BadRequestException(InvoiceMessage.NOT_FOUND_BY_ID); + + if (invoice.status !== InvoiceStatus.PENDING) throw new BadRequestException(InvoiceMessage.INVOICE_CAN_NOT_UPDATE); + + if (updateDto.items) { + // Remove existing items + await em.nativeDelete(InvoiceItem, { invoice: invoice.id }); + + // Create new items + const invoiceItems = updateDto.items.map((item) => { + const totalItemPrice = new Decimal(item.unitPrice * item.count - (item.unitPrice * item.count * (item.discount || 0)) / 100); + return em.create(InvoiceItem, { + name: item.name, + count: item.count, + unitPrice: new Decimal(item.unitPrice), + discount: new Decimal(item.discount || 0), + totalPrice: totalItemPrice, + invoice: invoice, + }); + }); + + // Calculate total price and tax + const totalPrice = invoiceItems.reduce((sum, item) => sum.add(item.totalPrice), new Decimal(0)); + const tax = totalPrice.mul(0.1); + + invoice.totalPrice = totalPrice.add(tax); + invoice.tax = tax; + invoice.items.set(invoiceItems); + } + + if (updateDto.companyId) { + const company = await this.companiesService.findOneByIdWithEntityManager(updateDto.companyId, em); + invoice.company = company; + } + + // Save the updated invoice + await em.persistAndFlush(invoice); + + await em.commit(); + + return { + message: InvoiceMessage.INVOICE_UPDATED, + invoice, + }; + } catch (error) { + await em.rollback(); + throw error; + } + } + //********************************** */ + + async approveInvoiceRequest(invoiceId: string, userId: string) { + const em = this.em.fork(); + + try { + await em.begin(); + + const user = await this.usersService.findOneByIdWithEntityManager(userId, em); + + const invoice = await this.validateInvoiceForApproval(invoiceId, userId, em); + + const existCode = await this.otpService.checkExistOtp(user.phone, "INVOICE_VERIFY"); + if (existCode) { + return { + message: AuthMessage.OTP_ALREADY_SENT, + ttlSecond: existCode, + }; + } + + // Generate and send OTP + const otpCode = await this.otpService.generateAndSetInCache(user.phone, "INVOICE_VERIFY"); + const items = invoice.items.map((item) => item.name).join(", "); + + await this.smsService.sendInvoiceVerifyCode( + user.phone, + otpCode, + invoice.numericId, + new Decimal(invoice.totalPrice).toNumber(), + items, + ); + + await em.commit(); + + return { + message: AuthMessage.OTP_SENT, + otpCode, + }; + } catch (error) { + await em.rollback(); + throw error; + } + } + + //********************************** */ + + async approveInvoiceByUser(invoiceId: string, userId: string, verifyOtpDto: VerifyOtpWithUserId) { + const em = this.em.fork(); + + try { + await em.begin(); + + const { code } = verifyOtpDto; + + const user = await this.usersService.findOneByIdWithEntityManager(userId, em); + + const isValid = await this.otpService.verifyOtp(user.phone, code, "INVOICE_VERIFY"); + if (!isValid) throw new BadRequestException(AuthMessage.INVALID_OTP); + + const invoice = await this.validateInvoiceForApproval(invoiceId, userId, em); + + invoice.status = InvoiceStatus.WAIT_PAYMENT; + await em.persistAndFlush(invoice); + + await this.notificationQueue.addApprovedInvoiceNotification(userId, { + invoiceId: invoice.numericId.toString(), + dueDate: invoice.dueDate, + createDate: invoice.createdAt, + price: new Decimal(invoice.totalPrice).toNumber(), + userPhone: user.phone, + userEmail: user.email, + items: invoice.items.map((item) => item.name).join(", "), + }); + + await this.otpService.delOtpFormCache(user.phone, "INVOICE_VERIFY"); + + await em.commit(); + + return { + message: InvoiceMessage.APPROVED, + invoice, + }; + } catch (error) { + await em.rollback(); + throw error; + } + } + + //********************************** */ + private async validateInvoiceForApproval(invoiceId: string, userId: string, em: EntityManager): Promise { + const invoice = await em.findOne(Invoice, { id: invoiceId, company: { user: { id: userId } } }, { populate: ["items"] }); + + if (!invoice) throw new BadRequestException(InvoiceMessage.NOT_FOUND_BY_ID); + + if (invoice.status === InvoiceStatus.WAIT_PAYMENT) throw new BadRequestException(InvoiceMessage.ALREADY_APPROVED); + + if (invoice.status === InvoiceStatus.PAID) throw new BadRequestException(InvoiceMessage.INVOICE_ALREADY_PAID); + + if (dayjs().isAfter(dayjs(invoice.dueDate).add(INVOICE.MAX_DAYS_AFTER_OVERDUE, "day"))) { + invoice.status = InvoiceStatus.EXPIRED; + await em.persistAndFlush(invoice); + throw new BadRequestException(InvoiceMessage.INVOICE_IS_OVERDUE); + } + + if (invoice.status !== InvoiceStatus.PENDING) throw new BadRequestException(InvoiceMessage.INVOICE_CAN_NOT_APPROVED); + + return invoice; + } + + ///********************************** */ + + ///********************************** */ + + async getInvoices(queryDto: InvoicesSearchQueryDto) { + const [invoices, count] = await this.invoiceRepository.getInvoicesForAdmin(queryDto); + + // Get unique users that have invoices using MikroORM's query builder + const usersThatHaveInvoices = await this.em + .createQueryBuilder(User, "user") + .select(["user.id", "user.firstName", "user.lastName", "user.email", "user.phone"]) + .distinct() + .join("user.invoices", "invoice") + .getResult(); + + return { + users: usersThatHaveInvoices, + invoices, + count, + paginate: true, + }; + } + + //*********************************** */ + async getInvoiceById(invoiceId: string, role: RoleEnum, userId: string) { + let invoice: Invoice | null; + + if (role === RoleEnum.ADMIN) { + invoice = await this.invoiceRepository.findOne(invoiceId, { populate: ["items", "company"] }); + } else { + invoice = await this.invoiceRepository.findOne({ id: invoiceId, company: { user: { id: userId } } }, { populate: ["items"] }); + } + + if (!invoice) throw new BadRequestException(InvoiceMessage.NOT_FOUND_BY_ID); + + return { + invoice, + }; + } + + //*********************************** */ + + async getUserInvoices(queryDto: UserInvoicesSearchQueryDto, userId: string) { + const { limit, skip } = PaginationUtils(queryDto); + + const qb = this.em + .createQueryBuilder(Invoice, "invoice") + .leftJoinAndSelect("invoice.items", "items") + .where({ company: { user: { id: userId } } }) + .orderBy({ createdAt: "DESC" }); + + if (queryDto.status) { + qb.andWhere({ status: queryDto.status }); + } + + const [invoices, count] = await qb.limit(limit).offset(skip).getResultAndCount(); + + return { + invoices, + count, + paginate: true, + }; + } + //*********************************** */ + + // async payInvoice(invoiceId: string, userId: string, em = this.em.fork()) { + // let transactionStarted = false; + + // try { + // if (!em.isInTransaction) { + // await em.begin(); + // transactionStarted = true; + // } + + // const user = await this.usersService.findOneByIdWithEntityManager(userId, em); + + // const invoice = await this.getPendingInvoiceByIdWithQueryRunner(invoiceId, user.id, em); + + // if (invoice.status !== InvoiceStatus.WAIT_PAYMENT) throw new BadRequestException(InvoiceMessage.INVOICE_CAN_NOT_PAID); + + // const userWallet = await this.walletsService.getWalletByUserId(user.id, em); + + // if (userWallet.balance < invoice.totalPrice) throw new BadRequestException(WalletMessage.INSUFFICIENT_BALANCE); + + // userWallet.balance = new Decimal(userWallet.balance).sub(invoice.totalPrice); + + // await em.persistAndFlush(userWallet); + + // if (invoice.items[0]?.subscriptionPlan) { + // const userSubscription = invoice.items[0].subscriptionPlan; + // userSubscription.status = SubscriptionStatus.ACTIVE; + + // // + // await queryRunner.manager.save(UserSubscription, userSubscription); + // await this.walletsService.createSubscriptionTransaction(invoice.totalPrice, userWallet.id, queryRunner); + // await this.addNotifyForWalletDeduction(invoice, user, userWallet, WalletMessage.SUBSCRIPTION_WALLET_TRANSFER); + // await this.addNotifyAdminForSubscriptionPaymentToQueue(invoice); + // // + // } else if (invoice.items[0]?.supportPlan) { + // const oldUserSupportPlan = await queryRunner.manager.findOne(UserSupportPlan, { + // where: { user: { id: user.id }, status: UserSupportPlanStatus.ACTIVE }, + // }); + + // if (oldUserSupportPlan) { + // await queryRunner.manager.update(UserSupportPlan, oldUserSupportPlan.id, { status: UserSupportPlanStatus.INACTIVE }); + // } + + // const userSupportPlan = invoice.items[0].supportPlan; + // userSupportPlan.status = UserSupportPlanStatus.ACTIVE; + + // await queryRunner.manager.save(UserSupportPlan, userSupportPlan); + // await this.walletsService.createSupportPlanTransaction(invoice.totalPrice, userWallet.id, queryRunner); + // await this.addNotifyForWalletDeduction(invoice, user, userWallet, WalletMessage.SUPPORT_PLAN_WALLET_TRANSFER); + // // + // } else { + // await this.walletsService.createInvoiceTransaction(invoice.totalPrice, userWallet.id, queryRunner); + // await this.addNotifyForWalletDeduction(invoice, user, userWallet, WalletMessage.INVOICE_WALLET_TRANSFER); + // } + // invoice.status = InvoiceStatus.PAID; + // invoice.paidAt = dayjs().toDate(); + + // await queryRunner.manager.save(Invoice, invoice); + + // await this.notificationQueue.addBillInvoiceNotification(userId, { + // invoiceId: invoice.numericId.toString(), + // dueDate: invoice.dueDate, + // createDate: invoice.createdAt, + // price: new Decimal(invoice.totalPrice).toNumber(), + // userPhone: user.phone, + // userEmail: user.email, + // items: invoice.items.map((item) => item.name).join(", "), + // paidAt: invoice.paidAt, + // }); + + // if (transactionStarted) await queryRunner.commitTransaction(); + + // return { + // message: InvoiceMessage.INVOICE_PAID, + // invoice, + // }; + // } catch (error) { + // if (transactionStarted) await queryRunner.rollbackTransaction(); + // throw error; + // } finally { + // if (transactionStarted) await queryRunner.release(); + // } + // } + + //*********************************** */ + + async getPendingInvoiceByIdWithQueryRunner(invoiceId: string, userId: string, em: EntityManager) { + const invoice = await em.findOne( + Invoice, + { company: { user: { id: userId } }, status: InvoiceStatus.WAIT_PAYMENT, id: invoiceId }, + { populate: ["items"] }, + ); + if (!invoice) throw new BadRequestException(InvoiceMessage.NOT_FOUND_BY_ID); + return invoice; + } + + //*********************************** */ + + async getInvoicesCount() { + const today = new Date(); + const startOfDay = new Date(today.setHours(0, 0, 0, 0)); + const endOfDay = new Date(today.setHours(23, 59, 59, 999)); + + const count = await this.em.count(Invoice, { + createdAt: { $gte: startOfDay, $lte: endOfDay }, + }); + return count; + } + + //*********************************** */ + + async countUserInvoices(userId: string) { + const invoiceCount = await this.em.count(Invoice, { + company: { user: { id: userId } }, + status: InvoiceStatus.WAIT_PAYMENT, + }); + return invoiceCount; + } + + //********************************** */ + + private async calculateRecurringDelay(recurringPeriod: RecurringPeriodEnum) { + let delayMs: number; + switch (recurringPeriod) { + case RecurringPeriodEnum.WEEKLY: + delayMs = dayjs().add(1, "week").subtract(INVOICE.DAYS_BEFORE_OVERDUE, "day").diff(dayjs()); + break; + case RecurringPeriodEnum.MONTHLY: + delayMs = dayjs().add(1, "month").subtract(7, "day").diff(dayjs()); + break; + case RecurringPeriodEnum.QUARTERLY: + delayMs = dayjs().add(3, "month").subtract(7, "day").diff(dayjs()); + break; + case RecurringPeriodEnum.BIANNUALLY: + delayMs = dayjs().add(6, "month").subtract(7, "day").diff(dayjs()); + break; + case RecurringPeriodEnum.ANNUALLY: + delayMs = dayjs().add(1, "year").subtract(7, "day").diff(dayjs()); + break; + default: + delayMs = dayjs().add(1, "month").subtract(7, "day").diff(dayjs()); + } + + this.logger.log(`Calculated recurring delay: ${delayMs}ms for period: ${recurringPeriod}`); + return delayMs; + } + + //*********************************** */ + private async scheduleInvoiceJobs(invoice: Invoice, createDto?: CreateInvoiceDto) { + // Add to queue for billing reminder + await this.invoiceQueue.add( + INVOICE.REMINDER_JOB_NAME, + { invoiceId: invoice.id }, + { + delay: dayjs(invoice.dueDate).subtract(INVOICE.DAYS_BEFORE_OVERDUE, "day").diff(dayjs()), + attempts: INVOICE.REMINDER_JOB_ATTEMPTS, + backoff: { type: "exponential", delay: INVOICE.REMINDER_JOB_BACKOFF }, + priority: INVOICE.REMINDER_JOB_PRIORITY, + }, + ); + + if (createDto?.isRecurring && !createDto?.recurringPeriod) throw new BadRequestException(InvoiceMessage.RECURRING_PERIOD_REQUIRED); + + // Add to queue for recurring invoice if applicable + if (createDto?.isRecurring && createDto?.recurringPeriod) { + await this.invoiceQueue.add( + INVOICE.RECURRING_JOB_NAME, + { + invoiceId: invoice.id, + adminCreated: true, + }, + { + delay: await this.calculateRecurringDelay(createDto.recurringPeriod), + attempts: INVOICE.RECURRING_JOB_ATTEMPTS, + backoff: { type: "exponential", delay: INVOICE.RECURRING_JOB_BACKOFF }, + priority: INVOICE.RECURRING_JOB_PRIORITY, + }, + ); + + this.logger.log(`Scheduled recurring invoice for user ${createDto.companyId} with interval ${createDto.recurringPeriod}`); + } + } + //*********************************** */ +} diff --git a/src/modules/invoices/queue/invoice.processor.ts b/src/modules/invoices/queue/invoice.processor.ts new file mode 100755 index 0000000..7470017 --- /dev/null +++ b/src/modules/invoices/queue/invoice.processor.ts @@ -0,0 +1,236 @@ +import { EntityManager } from "@mikro-orm/postgresql"; +import { InjectQueue, Processor } from "@nestjs/bullmq"; +import { Job, Queue } from "bullmq"; +import dayjs from "dayjs"; +import Decimal from "decimal.js"; + +import { WorkerProcessor } from "../../../common/queues/worker.processor"; +import { NotificationQueue } from "../../notifications/queue/notification.queue"; +import { INVOICE } from "../constants"; +import { Invoice } from "../entities/invoice.entity"; +import { InvoiceStatus } from "../enums/invoice-status.enum"; + +@Processor(INVOICE.QUEUE_NAME, { concurrency: 2 }) +export class InvoiceProcessor extends WorkerProcessor { + constructor( + @InjectQueue(INVOICE.QUEUE_NAME) private readonly invoiceQueue: Queue, + private readonly notificationQueue: NotificationQueue, + private readonly em: EntityManager, + ) { + super(); + } + + async process(job: Job, token?: string) { + this.logger.log(job); + switch (job.name) { + case INVOICE.REMINDER_JOB_NAME: + return this.sendBillInvoiceReminder(job, token); + case INVOICE.RECURRING_JOB_NAME: + return this.createRecurringInvoice(job, token); + + default: + this.logger.error(`Unknown job name: ${job.name}`); + return; + } + } + //********************************** */ + private async createRecurringInvoice(job: Job<{ invoiceId: string; adminCreated: boolean }>, token?: string): Promise { + const { invoiceId, adminCreated } = job.data; + this.logger.log(`Creating recurring invoice for original invoice: ${invoiceId} ${token ? `with token: ${token}` : ""}`); + + const em = this.em.fork(); + + try { + await em.begin(); + + const invoice = await this.fetchInvoice(invoiceId, em); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + adminCreated ? await this.handleAdminCreatedInvoice(invoice, em) : null; + + await em.commit(); + this.logger.log(`Recurring invoice for ${invoiceId} created successfully`); + } catch (error) { + this.logger.error(`Failed to create recurring invoice for ${invoiceId}:`, error); + await em.rollback(); + throw error; + } + } + //********************************** */ + + private async validateRecurringInvoice(invoice: Invoice): Promise { + if (!invoice.isRecurring) throw new Error(`Invoice ${invoice.id} is not set up for recurring billing`); + + if (invoice.maxRecurringCycles && invoice.currentRecurringCycle >= invoice.maxRecurringCycles) { + throw new Error(`Maximum recurring cycles (${invoice.maxRecurringCycles}) reached for invoice ${invoice.id}`); + } + + if (invoice.status === InvoiceStatus.CANCELLED) throw new Error(`Cannot create recurring invoice for cancelled invoice ${invoice.id}`); + } + + //********************************** */ + + private async handleAdminCreatedInvoice(invoice: Invoice, em: EntityManager): Promise { + this.logger.verbose(`Creating admin-initiated recurring invoice in draft status`); + + await this.validateRecurringInvoice(invoice); + + const newInvoice = em.create(Invoice, { + company: invoice.company, + status: InvoiceStatus.DRAFT, + dueDate: dayjs().add(INVOICE.DUEDATE, "day").toDate(), + totalPrice: invoice.totalPrice, + originalPrice: invoice.originalPrice, + tax: invoice.tax, + items: invoice.items, + isRecurring: invoice.isRecurring, + recurringPeriod: invoice.recurringPeriod, + maxRecurringCycles: invoice.maxRecurringCycles, + currentRecurringCycle: invoice.currentRecurringCycle + 1, + }); + + // Update the original invoice's current recurring cycle + invoice.currentRecurringCycle += 1; + await em.persistAndFlush(invoice); + + await em.persistAndFlush(newInvoice); + return newInvoice; + } + + //********************************** */ + + private async sendBillInvoiceReminder(job: Job<{ invoiceId: string }>, token?: string) { + this.logger.log(`Sending bill invoice reminder: ${job.data.invoiceId} to user. ${token}`); + + const em = this.em.fork(); + + try { + await em.begin(); + const invoice = await this.fetchInvoice(job.data.invoiceId, em); + + if (invoice.status === InvoiceStatus.PAID) { + this.logger.log(`Invoice ${invoice.id} is already paid.`); + await em.commit(); + return; + } + + if (this.isInvoiceOverdueForCancellation(invoice)) { + await this.cancelOverdueInvoice(invoice, em); + await em.commit(); + return; + } + + await this.applyLateFeeIfOverdue(invoice, em); + + await this.sendReminderNotification(invoice); + + await this.scheduleNextReminder(invoice.id); + + await em.commit(); + } catch (error) { + this.logger.error(`Failed to process invoice reminder:`, error); + await em.rollback(); + throw error; + } + } + //********************************** */ + private async fetchInvoice(invoiceId: string, em: EntityManager) { + const invoice = await em.findOne(Invoice, { id: invoiceId }, { populate: ["company", "items", "company.user"] }); + + if (!invoice) throw new Error(`Invoice not found: ${invoiceId}`); + return invoice; + } + //********************************** */ + + private isInvoiceOverdueForCancellation(invoice: Invoice): boolean { + const tenDaysAfterDueDate = dayjs(invoice.dueDate).add(INVOICE.MAX_DAYS_AFTER_OVERDUE, "day"); + return dayjs().isAfter(tenDaysAfterDueDate); + } + //********************************** */ + + private async cancelOverdueInvoice(invoice: Invoice, em: EntityManager) { + invoice.status = InvoiceStatus.CANCELLED; + await em.persistAndFlush(invoice); + + this.logger.log(`Invoice ${invoice.id} has been cancelled as it is more than 10 days overdue.`); + } + //********************************** */ + + private async applyLateFeeIfOverdue(invoice: Invoice, em: EntityManager) { + const today = dayjs(); + const dueDate = dayjs(invoice.dueDate); + + if (today.isAfter(dueDate)) { + // Mark invoice as overdue in status if not already paid + if (invoice.status === InvoiceStatus.PENDING || invoice.status === InvoiceStatus.WAIT_PAYMENT) { + this.logger.log(`Marking invoice ${invoice.id} as overdue`); + invoice.status = InvoiceStatus.OVERDUE; + } + + const daysOverdue = Math.min(today.diff(dueDate, "day"), INVOICE.MAX_DAYS_AFTER_OVERDUE); + + // Get the original price without late fees to calculate the fine correctly + const originalPrice = invoice.originalPrice || invoice.totalPrice; + const currentFine = invoice.lateFee || new Decimal(0); + const finePerDay = new Decimal(originalPrice).mul(INVOICE.FINE_PERCENTAGE); + const newTotalFine = finePerDay.mul(daysOverdue); + + if (newTotalFine.gt(currentFine)) { + this.logger.log(`Applying late fee for invoice ${invoice.id}: ${newTotalFine} (${daysOverdue} days overdue)`); + + // Calculate the difference between new and current fine + const additionalFine = newTotalFine.minus(currentFine); + + // Update the late fee + invoice.lateFee = newTotalFine; + + // Add only the additional fine to total price to avoid compounding + invoice.totalPrice = new Decimal(invoice.totalPrice).plus(additionalFine); + + await em.persistAndFlush(invoice); + + // Send overdue notification to the user + await this.notificationQueue.addInvoiceOverdueNotification(invoice.company.user.id, { + userPhone: invoice.company.user.phone, + userEmail: invoice.company.user.email, + invoiceId: invoice.numericId.toString(), + price: new Decimal(invoice.totalPrice).toNumber(), + lateFee: new Decimal(newTotalFine).toNumber(), + dueDate: invoice.dueDate, + createDate: invoice.createdAt, + items: invoice.items.map((item) => item.name).join(", "), + }); + } + } + } + //********************************** */ + + private async sendReminderNotification(invoice: Invoice) { + this.logger.log(`Sending invoice reminder to user: ${invoice.company.user.id}`); + await this.notificationQueue.addBillInvoiceReminderNotification(invoice.company.user.id, { + userPhone: invoice.company.user.phone, + userEmail: invoice.company.user.email, + invoiceId: invoice.numericId.toString(), + price: new Decimal(invoice.totalPrice).toNumber(), + dueDate: invoice.dueDate, + createDate: invoice.createdAt, + items: invoice.items.map((item) => item.name).join(", "), + }); + } + //********************************** */ + + private async scheduleNextReminder(invoiceId: string) { + this.logger.log(`Invoice ${invoiceId} is still unpaid. Scheduling a new reminder.`); + await this.invoiceQueue.add( + INVOICE.REMINDER_JOB_NAME, + { invoiceId }, + { + delay: INVOICE.REMINDER_REPEAT_DELAY, // 1 day delay in milliseconds + attempts: INVOICE.REMINDER_JOB_ATTEMPTS, + backoff: { type: "exponential", delay: INVOICE.REMINDER_JOB_BACKOFF }, + }, + ); + } + + //********************************** */ +} diff --git a/src/modules/invoices/repositories/invoice-items.repository.ts b/src/modules/invoices/repositories/invoice-items.repository.ts new file mode 100755 index 0000000..55d9256 --- /dev/null +++ b/src/modules/invoices/repositories/invoice-items.repository.ts @@ -0,0 +1,5 @@ +import { EntityRepository } from "@mikro-orm/postgresql"; + +import { InvoiceItem } from "../entities/invoice-item.entity"; + +export class InvoiceItemsRepository extends EntityRepository {} diff --git a/src/modules/invoices/repositories/invoices.repository.ts b/src/modules/invoices/repositories/invoices.repository.ts new file mode 100755 index 0000000..9e73cc9 --- /dev/null +++ b/src/modules/invoices/repositories/invoices.repository.ts @@ -0,0 +1,68 @@ +import { FilterQuery } from "@mikro-orm/core"; +import { EntityRepository } from "@mikro-orm/postgresql"; + +import { PaginationUtils } from "../../utils/providers/pagination.utils"; +import { InvoicesSearchQueryDto } from "../DTO/invoices-search-query.dto"; +import { Invoice } from "../entities/invoice.entity"; + +export class InvoicesRepository extends EntityRepository { + // + async getInvoicesForAdmin(queryDto: InvoicesSearchQueryDto) { + const { limit, skip } = PaginationUtils(queryDto); + + const where: FilterQuery = {}; + + if (queryDto.q) { + where.$or = [ + { company: { user: { firstName: { $ilike: `%${queryDto.q}%` } } } }, + { company: { user: { lastName: { $ilike: `%${queryDto.q}%` } } } }, + { company: { user: { email: { $ilike: `%${queryDto.q}%` } } } }, + ]; + } + + if (queryDto.status) { + where.status = queryDto.status; + } + + if (queryDto.companyId) { + where.company = queryDto.companyId; + } + + if (queryDto.since || queryDto.to) { + const dateConditions = []; + + if (queryDto.since) { + const sinceDate = new Date(queryDto.since); + dateConditions.push({ createdAt: { $gte: sinceDate } }, { dueDate: { $gte: sinceDate } }, { paidAt: { $gte: sinceDate } }); + } + + if (queryDto.to) { + const toDate = new Date(queryDto.to); + dateConditions.push({ createdAt: { $lte: toDate } }, { dueDate: { $lte: toDate } }, { paidAt: { $lte: toDate } }); + } + + if (queryDto.since && queryDto.to) { + const sinceDate = new Date(queryDto.since); + const toDate = new Date(queryDto.to); + where.$and = [ + { + $or: [ + { createdAt: { $gte: sinceDate, $lte: toDate } }, + { dueDate: { $gte: sinceDate, $lte: toDate } }, + { paidAt: { $gte: sinceDate, $lte: toDate } }, + ], + }, + ]; + } else { + where.$or = dateConditions; + } + } + + return this.findAndCount(where, { + populate: ["company", "items"], + orderBy: { createdAt: "DESC" }, + limit, + offset: skip, + }); + } +} diff --git a/src/modules/tickets/DTO/create-ticket.dto.ts b/src/modules/tickets/DTO/create-ticket.dto.ts index dd9a698..c871c72 100755 --- a/src/modules/tickets/DTO/create-ticket.dto.ts +++ b/src/modules/tickets/DTO/create-ticket.dto.ts @@ -23,7 +23,7 @@ export class CreateTicketDto { companyId?: string; @IsNotEmpty({ message: TicketMessageEnum.CATEGORY_ID_REQUIRED }) - @IsUUID("4", { message: TicketMessageEnum.CATEGORY_ID_SHOULD_BE_UUID }) + @IsUUID("7", { message: TicketMessageEnum.CATEGORY_ID_SHOULD_BE_UUID }) @ApiProperty({ description: "Category ID of the ticket", example: "123e4567-e89b-12d3-a456-426614174000" }) categoryId: string; diff --git a/src/modules/tickets/DTO/refer-ticket.dto.ts b/src/modules/tickets/DTO/refer-ticket.dto.ts index 08e03be..4a8c697 100644 --- a/src/modules/tickets/DTO/refer-ticket.dto.ts +++ b/src/modules/tickets/DTO/refer-ticket.dto.ts @@ -5,7 +5,7 @@ import { UserMessage } from "../../../common/enums/message.enum"; export class ReferTicketDto { @IsNotEmpty({ message: UserMessage.ADMIN_ID_REQUIRED }) - @IsUUID("4", { message: UserMessage.ADMIN_ID_SHOULD_BE_UUID }) + @IsUUID("7", { message: UserMessage.ADMIN_ID_SHOULD_BE_UUID }) @ApiProperty({ description: "admin id", example: "123e4567-e89b-12d3-a456-426614174000" }) adminId: string; } diff --git a/src/modules/tickets/DTO/search-ticket-query.dto.ts b/src/modules/tickets/DTO/search-ticket-query.dto.ts index 8a53620..8978ef4 100755 --- a/src/modules/tickets/DTO/search-ticket-query.dto.ts +++ b/src/modules/tickets/DTO/search-ticket-query.dto.ts @@ -12,7 +12,7 @@ export class SearchTicketQueryDto extends PaginationDto { status?: TicketStatus; @IsOptional() - @IsUUID("4", { message: UserMessage.USER_ID_SHOULD_BE_A_UUID }) + @IsUUID("7", { message: UserMessage.USER_ID_SHOULD_BE_A_UUID }) @ApiPropertyOptional({ description: "user id", example: "123e4567-e89b-12d3-a456-426614174000" }) userId?: string; }