From f9f397e92a629192ebffa1b617912f8b3fd27946 Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Mon, 16 Feb 2026 23:46:59 +0330 Subject: [PATCH] add: bill module --- src/app.module.ts | 2 + src/modules/bills/bills.controller.ts | 58 ++++++ src/modules/bills/bills.module.ts | 19 ++ src/modules/bills/bills.service.ts | 166 ++++++++++++++++++ src/modules/bills/dto/bill-list-query.dto.ts | 22 +++ .../bills/dto/create-charge-bill.dto.ts | 13 ++ .../bills/dto/create-water-bill.dto.ts | 32 ++++ src/modules/bills/dto/update-bill.dto.ts | 5 + src/modules/bills/entities/bill.entity.ts | 22 +++ src/modules/bills/enums/bill-type.enum.ts | 4 + .../bills/repositories/bill.repository.ts | 37 ++++ .../companies/services/companies.service.ts | 15 +- .../invoices/entities/invoice.entity.ts | 4 + 13 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 src/modules/bills/bills.controller.ts create mode 100644 src/modules/bills/bills.module.ts create mode 100644 src/modules/bills/bills.service.ts create mode 100644 src/modules/bills/dto/bill-list-query.dto.ts create mode 100644 src/modules/bills/dto/create-charge-bill.dto.ts create mode 100644 src/modules/bills/dto/create-water-bill.dto.ts create mode 100644 src/modules/bills/dto/update-bill.dto.ts create mode 100644 src/modules/bills/entities/bill.entity.ts create mode 100755 src/modules/bills/enums/bill-type.enum.ts create mode 100755 src/modules/bills/repositories/bill.repository.ts diff --git a/src/app.module.ts b/src/app.module.ts index c939022..47606ea 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -16,6 +16,7 @@ import { rateLimitConfig } from "./configs/rateLimit.config"; import { HTTPLogger } from "./core/middlewares/logger.middleware"; import { AnnouncementsModule } from "./modules/announcements/announcement.module"; import { AuthModule } from "./modules/auth/auth.module"; +import { BillsModule } from './modules/bills/bills.module'; import { BusinessesModule } from "./modules/businesses/businesses.module"; import { ChatbotModule } from "./modules/chatbot/chatbot.module"; import { CompaniesModule } from "./modules/companies/companies.module"; @@ -65,6 +66,7 @@ import { UtilsModule } from "./modules/utils/utils.module"; SlidersModule, ChatbotModule, SearchModule, + BillsModule, ], }) export class AppModule implements NestModule { diff --git a/src/modules/bills/bills.controller.ts b/src/modules/bills/bills.controller.ts new file mode 100644 index 0000000..dd6c267 --- /dev/null +++ b/src/modules/bills/bills.controller.ts @@ -0,0 +1,58 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Query, + UseInterceptors, +} from "@nestjs/common"; +import { ApiOperation } from "@nestjs/swagger"; + +import { BillsService } from "./bills.service"; +import { BillListQueryDto } from "./dto/bill-list-query.dto"; +import { CreateChargeBillDto } from "./dto/create-charge-bill.dto"; +import { CreateWaterBillDto } from "./dto/create-water-bill.dto"; +import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; +import { BusinessDec } from "../../common/decorators/business.decorator"; +import { ParamDto } from "../../common/DTO/param.dto"; +import { BusinessInterceptor } from "../../core/interceptors/business.interceptor"; +import { Business } from "../businesses/entities/business.entity"; + +@Controller("bills") +@UseInterceptors(BusinessInterceptor) +@AuthGuards() +export class BillsController { + constructor(private readonly billsService: BillsService) {} + + @ApiOperation({ summary: "Create water bill" }) + @Post("water") + createWaterBill(@Body() dto: CreateWaterBillDto, @BusinessDec() business: Business) { + return this.billsService.createWaterBill(dto, business); + } + + @ApiOperation({ summary: "Create charge bill" }) + @Post("charge") + createChargeBill(@Body() dto: CreateChargeBillDto, @BusinessDec() business: Business) { + return this.billsService.createChargeBill(dto, business); + } + + @ApiOperation({ summary: "Get all bills with filters and pagination" }) + @Get() + findAll(@Query() queryDto: BillListQueryDto, @BusinessDec("id") businessId: string) { + return this.billsService.findAll(queryDto, businessId); + } + + @ApiOperation({ summary: "Get a bill by id" }) + @Get(":id") + findOne(@Param() paramDto: ParamDto, @BusinessDec("id") businessId: string) { + return this.billsService.findOne(paramDto.id, businessId); + } + + @ApiOperation({ summary: "Remove a bill" }) + @Delete(":id") + remove(@Param() paramDto: ParamDto, @BusinessDec("id") businessId: string) { + return this.billsService.remove(paramDto.id, businessId); + } +} diff --git a/src/modules/bills/bills.module.ts b/src/modules/bills/bills.module.ts new file mode 100644 index 0000000..8d0ef06 --- /dev/null +++ b/src/modules/bills/bills.module.ts @@ -0,0 +1,19 @@ +import { MikroOrmModule } from "@mikro-orm/nestjs"; +import { Module } from "@nestjs/common"; + +import { BillsController } from "./bills.controller"; +import { BillsService } from "./bills.service"; +import { Bill } from "./entities/bill.entity"; +import { BusinessesModule } from "../businesses/businesses.module"; +import { CompaniesModule } from "../companies/companies.module"; + +@Module({ + imports: [ + MikroOrmModule.forFeature([Bill]), + CompaniesModule, + BusinessesModule, + ], + controllers: [BillsController], + providers: [BillsService], +}) +export class BillsModule {} diff --git a/src/modules/bills/bills.service.ts b/src/modules/bills/bills.service.ts new file mode 100644 index 0000000..2757af3 --- /dev/null +++ b/src/modules/bills/bills.service.ts @@ -0,0 +1,166 @@ +import { EntityManager } from '@mikro-orm/postgresql'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import dayjs from 'dayjs'; +import Decimal from 'decimal.js'; + +import { BillListQueryDto } from './dto/bill-list-query.dto'; +import { CreateChargeBillDto } from './dto/create-charge-bill.dto'; +import { CreateWaterBillDto } from './dto/create-water-bill.dto'; +import { Bill } from './entities/bill.entity'; +import { BillType } from './enums/bill-type.enum'; +import { BillsRepository } from './repositories/bill.repository'; +import { CompanyMessage } from '../../common/enums/message.enum'; +import { Business } from '../businesses/entities/business.entity'; +import { Company } from '../companies/entities/company.entity'; +import { CompaniesService } from '../companies/services/companies.service'; +import { InvoiceItem } from '../invoices/entities/invoice-item.entity'; +import { Invoice } from '../invoices/entities/invoice.entity'; + +@Injectable() +export class BillsService { + constructor( + private readonly em: EntityManager, + private readonly companiesService: CompaniesService, + private readonly billRepository: BillsRepository, + ) {} + + async createWaterBill(dto: CreateWaterBillDto, business: Business) { + const { values, waterRate, dueDays } = dto; + const em = this.em.fork(); + + try { + await em.begin(); + + const bill = em.create(Bill, { type: BillType.WATER_BILL, business }); + em.persist(bill); + const dueDate = dayjs().add(dueDays, 'day').toDate(); + + const uniqueCompanyIds = [...new Set(values.map((v) => v.companyId))]; + const companies = await this.companiesService.findByIds(uniqueCompanyIds, em); + const companyMap = new Map(companies.map((c) => [c.id, c])); + + for (const value of values) { + const subtotal = new Decimal(waterRate).mul(value.waterUsage); + const tax = subtotal.mul(0.1); + const totalPrice = subtotal.add(tax); + const company = companyMap.get(value.companyId); + if (!company) { + throw new BadRequestException(CompanyMessage.COMPANY_NOT_FOUND); + } + + const invoice = em.create(Invoice, { + company, + totalPrice, + originalPrice: totalPrice, + dueDate, + tax, + business, + bill, + }); + + const invoiceItem = em.create(InvoiceItem, { + name: 'قبض آب', + count: value.waterUsage, + unitPrice: new Decimal(waterRate), + discount: new Decimal(0), + totalPrice, + invoice, + }); + invoice.items.add(invoiceItem); + + em.persist(invoice); + } + + await em.flush(); + await em.commit(); + + return bill; + } catch (error) { + await em.rollback(); + throw error; + } + } + + async createChargeBill(dto: CreateChargeBillDto, business: Business) { + const { chargeRate, dueDays } = dto; + const em = this.em.fork(); + + try { + await em.begin(); + + const bill = em.create(Bill, { type: BillType.MONTHLY_CHARGE, business }); + em.persist(bill); + const dueDate = dayjs().add(dueDays, 'day').toDate(); + + const companies = await this.companiesService.findAllBybusinessId(business.id, em); + + for (const company of companies) { + const subtotal = new Decimal(chargeRate); + const tax = subtotal.mul(0.1); + const totalPrice = subtotal.add(tax); + + const invoice = em.create(Invoice, { + company, + totalPrice, + originalPrice: totalPrice, + dueDate, + tax, + business, + bill, + }); + + const invoiceItem = em.create(InvoiceItem, { + name: 'قبض شارژ', + count: 1, + unitPrice: subtotal, + discount: new Decimal(0), + totalPrice, + invoice, + }); + invoice.items.add(invoiceItem); + + em.persist(invoice); + } + + await em.flush(); + await em.commit(); + + return bill; + } catch (error) { + await em.rollback(); + throw error; + } + } + + async findAll(queryDto: BillListQueryDto, businessId: string) { + const [bills, count] = await this.billRepository.getBillsList(queryDto, businessId); + return { + bills, + count, + paginate: true, + }; + } + + async findOne(id: string, businessId: string) { + const bill = await this.billRepository.findOne( + { id, business: { id: businessId } }, + { populate: ["invoices"] }, + ); + if (!bill) { + throw new BadRequestException("Bill not found"); + } + return { bill }; + } + + async remove(id: string, businessId: string) { + const bill = await this.billRepository.findOne({ + id, + business: { id: businessId }, + }); + if (!bill) { + throw new BadRequestException("Bill not found"); + } + await this.em.removeAndFlush(bill); + return { deleted: true }; + } +} diff --git a/src/modules/bills/dto/bill-list-query.dto.ts b/src/modules/bills/dto/bill-list-query.dto.ts new file mode 100644 index 0000000..d405f9c --- /dev/null +++ b/src/modules/bills/dto/bill-list-query.dto.ts @@ -0,0 +1,22 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsDateString, IsEnum, IsOptional } from "class-validator"; + +import { PaginationDto } from "../../../common/DTO/pagination.dto"; +import { BillType } from "../enums/bill-type.enum"; + +export class BillListQueryDto extends PaginationDto { + @IsOptional() + @IsEnum(BillType) + @ApiPropertyOptional({ enum: BillType, description: "Filter by bill type" }) + type?: BillType; + + @IsOptional() + @IsDateString() + @ApiPropertyOptional({ description: "Filter bills created since this date", example: "2025-01-01" }) + since?: string; + + @IsOptional() + @IsDateString() + @ApiPropertyOptional({ description: "Filter bills created until this date", example: "2025-12-31" }) + to?: string; +} diff --git a/src/modules/bills/dto/create-charge-bill.dto.ts b/src/modules/bills/dto/create-charge-bill.dto.ts new file mode 100644 index 0000000..f3b9579 --- /dev/null +++ b/src/modules/bills/dto/create-charge-bill.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsInt } from "class-validator"; + +export class CreateChargeBillDto { + @ApiProperty() + @IsInt() + chargeRate: number; + + @ApiProperty() + @IsInt() + dueDays:number; + +} diff --git a/src/modules/bills/dto/create-water-bill.dto.ts b/src/modules/bills/dto/create-water-bill.dto.ts new file mode 100644 index 0000000..5c12ef0 --- /dev/null +++ b/src/modules/bills/dto/create-water-bill.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { ArrayMinSize, IsArray, IsInt, IsNotEmpty, IsUUID, ValidateNested } from "class-validator"; + +export class ValueDto { + @IsNotEmpty({ message: ' BillMessage.COMPANY_ID_REQUIRED' }) + @ApiProperty({ description: "شناسه شرکت", }) + @IsUUID("7") + companyId: string; + + @ApiProperty() + @IsNotEmpty({ message: 'BillMessage.WATER_USAGE_REQUIRED' }) + @IsInt() + @ApiProperty({ description: "مصرف آب", example: 100 }) + waterUsage: number; +} + +export class CreateWaterBillDto { + @ApiProperty() + @IsInt() + waterRate:number; + + @ApiProperty() + @IsInt() + dueDays:number; + + @Type(() => ValueDto) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + values: ValueDto[]; +} diff --git a/src/modules/bills/dto/update-bill.dto.ts b/src/modules/bills/dto/update-bill.dto.ts new file mode 100644 index 0000000..ebab0c7 --- /dev/null +++ b/src/modules/bills/dto/update-bill.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/swagger'; + +import { CreateWaterBillDto } from './create-water-bill.dto'; + +export class UpdateBillDto extends PartialType(CreateWaterBillDto) {} diff --git a/src/modules/bills/entities/bill.entity.ts b/src/modules/bills/entities/bill.entity.ts new file mode 100644 index 0000000..abcd629 --- /dev/null +++ b/src/modules/bills/entities/bill.entity.ts @@ -0,0 +1,22 @@ +import { Cascade, Collection, Entity, EntityRepositoryType, Enum, ManyToOne, OneToMany } from "@mikro-orm/core"; + +import { BaseEntity } from "../../../common/entities/base.entity"; +import { Business } from "../../businesses/entities/business.entity"; +import { Invoice } from "../../invoices/entities/invoice.entity"; +import { BillType } from "../enums/bill-type.enum"; +import { BillsRepository } from "../repositories/bill.repository"; + +@Entity({ repository: () => BillsRepository }) +export class Bill extends BaseEntity { + + @Enum({ items: () => BillType }) + type: BillType; + + @ManyToOne(() => Business, { nullable: false, deleteRule: "cascade" }) + business!: Business; + + @OneToMany(() => Invoice, (invoice) => invoice.bill, { cascade: [Cascade.PERSIST, Cascade.REMOVE], orphanRemoval: true }) + invoices = new Collection(this); + + [EntityRepositoryType]?: BillsRepository; +} diff --git a/src/modules/bills/enums/bill-type.enum.ts b/src/modules/bills/enums/bill-type.enum.ts new file mode 100755 index 0000000..7c3bff8 --- /dev/null +++ b/src/modules/bills/enums/bill-type.enum.ts @@ -0,0 +1,4 @@ +export enum BillType { + MONTHLY_CHARGE = "monthly_charge", + WATER_BILL = "water_bill", +} diff --git a/src/modules/bills/repositories/bill.repository.ts b/src/modules/bills/repositories/bill.repository.ts new file mode 100755 index 0000000..efa302a --- /dev/null +++ b/src/modules/bills/repositories/bill.repository.ts @@ -0,0 +1,37 @@ +import { FilterQuery } from "@mikro-orm/core"; +import { EntityRepository } from "@mikro-orm/postgresql"; + +import { PaginationUtils } from "../../utils/providers/pagination.utils"; +import { BillListQueryDto } from "../dto/bill-list-query.dto"; +import { Bill } from "../entities/bill.entity"; + +export class BillsRepository extends EntityRepository { + async getBillsList(queryDto: BillListQueryDto, businessId: string) { + const { limit, skip } = PaginationUtils(queryDto); + + const where: FilterQuery = { + business: { id: businessId }, + }; + + if (queryDto.type) { + where.type = queryDto.type; + } + + if (queryDto.since || queryDto.to) { + where.createdAt = {}; + if (queryDto.since) { + (where.createdAt as Record).$gte = new Date(queryDto.since); + } + if (queryDto.to) { + (where.createdAt as Record).$lte = new Date(queryDto.to); + } + } + + return this.findAndCount(where, { + populate: ["invoices"], + orderBy: { createdAt: "DESC" }, + limit, + offset: skip, + }); + } +} diff --git a/src/modules/companies/services/companies.service.ts b/src/modules/companies/services/companies.service.ts index 988eb45..14d6665 100644 --- a/src/modules/companies/services/companies.service.ts +++ b/src/modules/companies/services/companies.service.ts @@ -29,7 +29,7 @@ export class CompaniesService { private readonly passwordService: PasswordService, private readonly em: EntityManager, private readonly companyRequestRepository: CompanyRequestRepository, - ) {} + ) { } async create(createDto: CreateCompanyDto, business: Business) { const em = this.em.fork(); @@ -458,4 +458,17 @@ export class CompaniesService { return company; } + + async findByIds(comapnyIds: string[], em: EntityManager) { + const companies = await em.find(Company, { + id: { $in: comapnyIds } + }); + if (companies.length !== comapnyIds.length) throw new BadRequestException(CompanyMessage.COMPANY_NOT_FOUND); + return companies; + } + + async findAllBybusinessId(businessId: string, em: EntityManager) { + const companies = await em.find(Company, { business: { id: businessId } }); + return companies; + } } diff --git a/src/modules/invoices/entities/invoice.entity.ts b/src/modules/invoices/entities/invoice.entity.ts index f969893..81e0130 100755 --- a/src/modules/invoices/entities/invoice.entity.ts +++ b/src/modules/invoices/entities/invoice.entity.ts @@ -3,6 +3,7 @@ import { Decimal } from "decimal.js"; import { InvoiceItem } from "./invoice-item.entity"; import { BaseEntity } from "../../../common/entities/base.entity"; +import { Bill } from "../../bills/entities/bill.entity"; import { Business } from "../../businesses/entities/business.entity"; import { Company } from "../../companies/entities/company.entity"; import { Payment } from "../../payments/entities/payment.entity"; @@ -56,6 +57,9 @@ export class Invoice extends BaseEntity { @ManyToOne(() => Business, { nullable: false, deleteRule: "cascade" }) business!: Business; + @ManyToOne(() => Bill, { nullable: true, deleteRule: "cascade" }) + bill?: Bill; + //----------------------------------- @OneToMany(() => InvoiceItem, (item) => item.invoice, { cascade: [Cascade.PERSIST, Cascade.REMOVE], orphanRemoval: true })