This commit is contained in:
Mahyargdz
2025-05-13 09:45:15 +03:30
parent f18295b628
commit 1efc3a2795
23 changed files with 1277 additions and 14 deletions
+2
View File
@@ -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 {
+2
View File
@@ -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 معتبر باشد",
}
+3 -1
View File
@@ -13,12 +13,14 @@ export const databaseConfig: MikroOrmModuleAsyncOptions = {
const DB_USER = configService.getOrThrow<string>("DB_USER");
const DB_HOST = configService.getOrThrow<string>("DB_HOST");
const DB_PORT = configService.getOrThrow<number>("DB_PORT");
const encodedPassword = encodeURIComponent(DB_PASS);
return {
driver: PostgreSqlDriver,
autoLoadEntities: true,
dbName: configService.getOrThrow<string>("DB_NAME"),
debug: configService.get<string>("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: {
@@ -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<CompanyService>(this);
@OneToMany(() => Invoice, (invoice) => invoice.company)
invoices = new Collection<Invoice>(this);
//-----------------------------------
@ManyToOne(() => Industry, { deleteRule: "restrict" })
@@ -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<Company> {
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),
// }));
}
}
@@ -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);
+67
View File
@@ -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;
}
+35
View File
@@ -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"]) {}
+5
View File
@@ -0,0 +1,5 @@
import { PartialType } from "@nestjs/swagger";
import { CreateInvoiceDto } from "./create-invoice.dto";
export class UpdateInvoiceDto extends PartialType(CreateInvoiceDto) {}
+31
View File
@@ -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,
});
+29
View File
@@ -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;
}
+56
View File
@@ -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<InvoiceItem>(this);
[EntityRepositoryType]?: InvoicesRepository;
}
@@ -0,0 +1,7 @@
export enum RecurringPeriodEnum {
WEEKLY = 1,
MONTHLY,
QUARTERLY,
BIANNUALLY,
ANNUALLY,
}
+9
View File
@@ -0,0 +1,9 @@
export enum InvoiceStatus {
DRAFT = "DRAFT",
PENDING = "PENDING",
WAIT_PAYMENT = "WAIT_PAYMENT",
PAID = "PAID",
OVERDUE = "OVERDUE",
EXPIRED = "EXPIRED",
CANCELLED = "CANCELLED",
}
+66
View File
@@ -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);
// }
}
+29
View File
@@ -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 {}
+556
View File
@@ -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<Invoice> {
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}`);
}
}
//*********************************** */
}
+236
View File
@@ -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<void> {
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<void> {
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<Invoice> {
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 },
},
);
}
//********************************** */
}
@@ -0,0 +1,5 @@
import { EntityRepository } from "@mikro-orm/postgresql";
import { InvoiceItem } from "../entities/invoice-item.entity";
export class InvoiceItemsRepository extends EntityRepository<InvoiceItem> {}
+68
View File
@@ -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<Invoice> {
//
async getInvoicesForAdmin(queryDto: InvoicesSearchQueryDto) {
const { limit, skip } = PaginationUtils(queryDto);
const where: FilterQuery<Invoice> = {};
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,
});
}
}
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
}
@@ -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;
}