import { EntityManager } from "@mikro-orm/postgresql"; import { InjectQueue } from "@nestjs/bullmq"; import { BadRequestException, Injectable } from "@nestjs/common"; import { Queue } from "bullmq"; import dayjs from "dayjs"; import Decimal from "decimal.js"; import { Workbook } from "exceljs"; import { BillListQueryDto } from "./dto/bill-list-query.dto"; import { CreateChargeBillDto } from "./dto/create-charge-bill.dto"; import { CreateWaterBillDto, CreateWaterBillFromExcelDto } 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 { INVOICE } from "../invoices/constants"; import { InvoiceItem } from "../invoices/entities/invoice-item.entity"; import { Invoice } from "../invoices/entities/invoice.entity"; import { NotificationQueue } from "../notifications/queue/notification.queue"; import { IFile } from "../utils/interfaces/IFile"; @Injectable() export class BillsService { readonly SEWAGE_RATE = 0.3; readonly TAX_RATE = 0.1; constructor( private readonly em: EntityManager, private readonly companiesService: CompaniesService, private readonly billRepository: BillsRepository, @InjectQueue(INVOICE.QUEUE_NAME) private readonly invoiceQueue: Queue, private readonly notificationQueue: NotificationQueue, ) {} async createWaterBill(dto: CreateWaterBillDto, business: Business) { const { values, waterRate, dueDays, title } = dto; const em = this.em.fork(); try { await em.begin(); const bill = em.create(Bill, { type: BillType.WATER_BILL, business, title }); 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 waterCost = new Decimal(waterRate).mul(value.waterUsage); const sewageCost = waterCost.mul(this.SEWAGE_RATE); const subtotal = waterCost.plus(sewageCost); const tax = subtotal.mul(this.TAX_RATE); const totalPrice = subtotal.plus(tax); const company = companyMap.get(value.companyId); if (!company) { throw new BadRequestException(CompanyMessage.COMPANY_NOT_FOUND); } if (totalPrice.eq(0)) { continue; } const invoice = em.create(Invoice, { company, totalPrice, originalPrice: totalPrice, dueDate, tax, business, bill, }); const invoiceItem = em.create(InvoiceItem, { name: title, count: 1, unitPrice: totalPrice, discount: new Decimal(0), totalPrice, invoice, }); invoice.items.add(invoiceItem); em.persist(invoice); } await em.commit(); // Ensure bill.id is available if (!bill.id) { await em.refresh(bill); } await this.addInvoiceReminderJobs(bill.id); return bill; } catch (error) { await em.rollback(); throw error; } } private async addInvoiceReminderJobs(billId: string) { const invoices = await this.em.find(Invoice, { bill: { id: billId } }, { populate: ["items", "company", "company.user"] }); for (const invoice of invoices) { await this.notificationQueue.addInvoiceCreationNotification(invoice.company.user.id, { invoiceId: invoice.numericId.toString(), dueDate: invoice.dueDate, createDate: invoice.createdAt, price: new Decimal(invoice.totalPrice).toNumber(), userPhone: invoice.company.user.phone, userEmail: invoice.company.user.email, items: invoice.items[0].name, paidAt: invoice.paidAt, }); 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, }, ); } } async createWaterBillByExcel(file: IFile, dto: CreateWaterBillFromExcelDto, business: Business) { const values = await this.parseWaterBillExcel(file.buffer); if (values.length === 0) { throw new BadRequestException("Excel file contains no valid data rows"); } return this.createWaterBill({ ...dto, values }, business); } private async parseWaterBillExcel(buffer: Buffer): Promise<{ companyId: string; waterUsage: number }[]> { const workbook = new Workbook(); // eslint-disable-next-line @typescript-eslint/no-explicit-any await workbook.xlsx.load(buffer as any); const worksheet = workbook.worksheets[0]; if (!worksheet) { return []; } const values: { companyId: string; waterUsage: number }[] = []; worksheet.eachRow((row) => { const companyIdRaw = row.getCell(1).value; const waterUsageRaw = row.getCell(3).value; const companyId = companyIdRaw != null ? String(companyIdRaw).trim() : ""; const waterUsage = typeof waterUsageRaw === "number" ? waterUsageRaw : Number(waterUsageRaw); if (companyId && !Number.isNaN(waterUsage) && waterUsage >= 0) { values.push({ companyId, waterUsage }); } }); return values; } async createChargeBill(dto: CreateChargeBillDto, business: Business) { const { chargeRate, dueDays, title } = dto; const em = this.em.fork(); try { await em.begin(); const bill = em.create(Bill, { type: BillType.MONTHLY_CHARGE, business, title }); 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 totalPrice = new Decimal(chargeRate).mul(company.metrage); if (totalPrice.eq(0)) { continue; } const invoice = em.create(Invoice, { company, totalPrice, originalPrice: totalPrice, dueDate, tax: new Decimal(0), business, bill, }); const invoiceItem = em.create(InvoiceItem, { name: title, count: 1, unitPrice: totalPrice, discount: new Decimal(0), totalPrice, invoice, }); invoice.items.add(invoiceItem); em.persist(invoice); } await em.commit(); // Ensure bill.id is available if (!bill.id) { await em.refresh(bill); } await this.addInvoiceReminderJobs(bill.id); 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 } }, { populate: ["invoices"] }); if (!bill) { throw new BadRequestException("Bill not found"); } await this.em.removeAndFlush(bill); return { deleted: true }; } }