invoice
This commit is contained in:
@@ -17,6 +17,7 @@ import { HTTPLogger } from "./core/middlewares/logger.middleware";
|
|||||||
import { AuthModule } from "./modules/auth/auth.module";
|
import { AuthModule } from "./modules/auth/auth.module";
|
||||||
import { CompaniesModule } from "./modules/companies/companies.module";
|
import { CompaniesModule } from "./modules/companies/companies.module";
|
||||||
import { IndustriesModule } from "./modules/industries/industries.module";
|
import { IndustriesModule } from "./modules/industries/industries.module";
|
||||||
|
import { InvoicesModule } from "./modules/invoices/invoices.module";
|
||||||
import { NotificationModule } from "./modules/notifications/notifications.module";
|
import { NotificationModule } from "./modules/notifications/notifications.module";
|
||||||
import { TicketsModule } from "./modules/tickets/tickets.module";
|
import { TicketsModule } from "./modules/tickets/tickets.module";
|
||||||
import { UploaderModule } from "./modules/uploader/uploader.module";
|
import { UploaderModule } from "./modules/uploader/uploader.module";
|
||||||
@@ -48,6 +49,7 @@ import { UtilsModule } from "./modules/utils/utils.module";
|
|||||||
CompaniesModule,
|
CompaniesModule,
|
||||||
UploaderModule,
|
UploaderModule,
|
||||||
TicketsModule,
|
TicketsModule,
|
||||||
|
InvoicesModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
|
|||||||
@@ -594,4 +594,6 @@ export const enum CompanyMessage {
|
|||||||
SERVICES_REQUIRED = "خدمات الزامی است",
|
SERVICES_REQUIRED = "خدمات الزامی است",
|
||||||
PRODUCTS_MUST_BE_ARRAY = "محصولات باید آرایه باشد",
|
PRODUCTS_MUST_BE_ARRAY = "محصولات باید آرایه باشد",
|
||||||
SERVICES_MUST_BE_ARRAY = "خدمات باید آرایه باشد",
|
SERVICES_MUST_BE_ARRAY = "خدمات باید آرایه باشد",
|
||||||
|
COMPANY_ID_REQUIRED = "شرکت مورد نیاز است",
|
||||||
|
COMPANY_ID_SHOULD_BE_A_UUID = "شناسه شرکت باید یک UUID معتبر باشد",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ export const databaseConfig: MikroOrmModuleAsyncOptions = {
|
|||||||
const DB_USER = configService.getOrThrow<string>("DB_USER");
|
const DB_USER = configService.getOrThrow<string>("DB_USER");
|
||||||
const DB_HOST = configService.getOrThrow<string>("DB_HOST");
|
const DB_HOST = configService.getOrThrow<string>("DB_HOST");
|
||||||
const DB_PORT = configService.getOrThrow<number>("DB_PORT");
|
const DB_PORT = configService.getOrThrow<number>("DB_PORT");
|
||||||
|
const encodedPassword = encodeURIComponent(DB_PASS);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
driver: PostgreSqlDriver,
|
driver: PostgreSqlDriver,
|
||||||
autoLoadEntities: true,
|
autoLoadEntities: true,
|
||||||
dbName: configService.getOrThrow<string>("DB_NAME"),
|
dbName: configService.getOrThrow<string>("DB_NAME"),
|
||||||
debug: configService.get<string>("NODE_ENV") !== "production",
|
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" },
|
ensureDatabase: { forceCheck: true, create: true, schema: "update" },
|
||||||
forceUtcTimezone: true,
|
forceUtcTimezone: true,
|
||||||
pool: {
|
pool: {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { CompanyProduct } from "./company-product.entity";
|
|||||||
import { CompanyService } from "./company-service.entity";
|
import { CompanyService } from "./company-service.entity";
|
||||||
import { BaseEntity } from "../../../common/entities/base.entity";
|
import { BaseEntity } from "../../../common/entities/base.entity";
|
||||||
import { Industry } from "../../industries/entities/industry.entity";
|
import { Industry } from "../../industries/entities/industry.entity";
|
||||||
|
import { Invoice } from "../../invoices/entities/invoice.entity";
|
||||||
import { User } from "../../users/entities/user.entity";
|
import { User } from "../../users/entities/user.entity";
|
||||||
import { CompanyStatus } from "../enums/company-status.enum";
|
import { CompanyStatus } from "../enums/company-status.enum";
|
||||||
import { CompanyRepository } from "../repositories/company.repository";
|
import { CompanyRepository } from "../repositories/company.repository";
|
||||||
@@ -51,6 +52,9 @@ export class Company extends BaseEntity {
|
|||||||
@OneToMany(() => CompanyService, (service) => service.company, { cascade: [Cascade.ALL] })
|
@OneToMany(() => CompanyService, (service) => service.company, { cascade: [Cascade.ALL] })
|
||||||
services = new Collection<CompanyService>(this);
|
services = new Collection<CompanyService>(this);
|
||||||
|
|
||||||
|
@OneToMany(() => Invoice, (invoice) => invoice.company)
|
||||||
|
invoices = new Collection<Invoice>(this);
|
||||||
|
|
||||||
//-----------------------------------
|
//-----------------------------------
|
||||||
|
|
||||||
@ManyToOne(() => Industry, { deleteRule: "restrict" })
|
@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 { PaginationUtils } from "../../utils/providers/pagination.utils";
|
||||||
import { CompanyListQueryDto } from "../DTO/company-list-query.dto";
|
import { CompanyListQueryDto } from "../DTO/company-list-query.dto";
|
||||||
@@ -9,17 +9,57 @@ export class CompanyRepository extends EntityRepository<Company> {
|
|||||||
async getCompaniesListForAdmin(queryDto: CompanyListQueryDto) {
|
async getCompaniesListForAdmin(queryDto: CompanyListQueryDto) {
|
||||||
const { limit, skip } = PaginationUtils(queryDto);
|
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,
|
deletedAt: null,
|
||||||
...(queryDto.status && { status: queryDto.status }),
|
...(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.isActive !== undefined && { isActive: queryDto.isActive === 1 }),
|
||||||
...(queryDto.registrationDate && { createdAt: { $gte: queryDto.registrationDate, $lte: queryDto.registrationDate } }),
|
});
|
||||||
...(queryDto.name && { name: { $ilike: `%${queryDto.name}%` } }),
|
|
||||||
|
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,
|
||||||
},
|
},
|
||||||
{ offset: skip, limit, orderBy: { createdAt: "DESC" } },
|
});
|
||||||
);
|
}
|
||||||
|
|
||||||
|
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) {
|
private async findCompanyById(id: string) {
|
||||||
const company = await this.companyRepository.findOne({ id, deletedAt: null });
|
const company = await this.companyRepository.findOne({ id, deletedAt: null });
|
||||||
if (!company) throw new BadRequestException(CompanyMessage.COMPANY_NOT_FOUND);
|
if (!company) throw new BadRequestException(CompanyMessage.COMPANY_NOT_FOUND);
|
||||||
|
|||||||
+67
@@ -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
@@ -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
@@ -0,0 +1,5 @@
|
|||||||
|
import { PartialType } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { CreateInvoiceDto } from "./create-invoice.dto";
|
||||||
|
|
||||||
|
export class UpdateInvoiceDto extends PartialType(CreateInvoiceDto) {}
|
||||||
Executable
+31
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,9 @@
|
|||||||
|
export enum InvoiceStatus {
|
||||||
|
DRAFT = "DRAFT",
|
||||||
|
PENDING = "PENDING",
|
||||||
|
WAIT_PAYMENT = "WAIT_PAYMENT",
|
||||||
|
PAID = "PAID",
|
||||||
|
OVERDUE = "OVERDUE",
|
||||||
|
EXPIRED = "EXPIRED",
|
||||||
|
CANCELLED = "CANCELLED",
|
||||||
|
}
|
||||||
Executable
+66
@@ -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);
|
||||||
|
// }
|
||||||
|
}
|
||||||
Executable
+29
@@ -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
@@ -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
@@ -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> {}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ export class CreateTicketDto {
|
|||||||
companyId?: string;
|
companyId?: string;
|
||||||
|
|
||||||
@IsNotEmpty({ message: TicketMessageEnum.CATEGORY_ID_REQUIRED })
|
@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" })
|
@ApiProperty({ description: "Category ID of the ticket", example: "123e4567-e89b-12d3-a456-426614174000" })
|
||||||
categoryId: string;
|
categoryId: string;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { UserMessage } from "../../../common/enums/message.enum";
|
|||||||
|
|
||||||
export class ReferTicketDto {
|
export class ReferTicketDto {
|
||||||
@IsNotEmpty({ message: UserMessage.ADMIN_ID_REQUIRED })
|
@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" })
|
@ApiProperty({ description: "admin id", example: "123e4567-e89b-12d3-a456-426614174000" })
|
||||||
adminId: string;
|
adminId: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export class SearchTicketQueryDto extends PaginationDto {
|
|||||||
status?: TicketStatus;
|
status?: TicketStatus;
|
||||||
|
|
||||||
@IsOptional()
|
@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" })
|
@ApiPropertyOptional({ description: "user id", example: "123e4567-e89b-12d3-a456-426614174000" })
|
||||||
userId?: string;
|
userId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user