/** * ════════════════════════════════════════════════════════════ * مُصادَق (Musadaq) — Invoices Service * ════════════════════════════════════════════════════════════ */ import { Injectable, NotFoundException, ForbiddenException, Inject, forwardRef, InternalServerErrorException, StreamableFile, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import * as fs from 'fs'; import * as path from 'path'; import { InjectQueue } from '@nestjs/bull'; import { Queue } from 'bull'; import { Invoice, InvoiceStatus } from './entities/invoice.entity'; import { LocalStorageService } from '../../services/storage/local-storage.service'; import { SubscriptionsService } from '../subscriptions/subscription.service'; import { UBLGeneratorService } from './ubl-generator.service'; import { JoFotaraGatewayService } from './jofotara-gateway.service'; import { CompaniesService } from '../companies/company.service'; @Injectable() export class InvoicesService { constructor( @InjectRepository(Invoice) private invoiceRepository: Repository, private localStorageService: LocalStorageService, @Inject(forwardRef(() => SubscriptionsService)) private subscriptionsService: SubscriptionsService, @InjectQueue('invoice-processing') private invoiceQueue: Queue, private ublGenerator: UBLGeneratorService, private jofotaraGateway: JoFotaraGatewayService, @Inject(forwardRef(() => CompaniesService)) private companiesService: CompaniesService, ) {} /** * رفع فاتورة جديدة وبدء المعالجة */ async upload( tenantId: string, companyId: string, file: Express.Multer.File, ): Promise { const canUpload = await this.subscriptionsService.checkInvoiceLimit(tenantId); if (!canUpload) { throw new ForbiddenException('Monthly invoice limit reached for your current plan'); } const fileName = `${Date.now()}-${file.originalname}`; const filePath = await this.localStorageService.saveFile( tenantId, companyId, fileName, file.buffer, ); const invoice = this.invoiceRepository.create({ tenant_id: tenantId, company_id: companyId, original_file_path: filePath, status: InvoiceStatus.UPLOADED, }); const savedInvoice = await this.invoiceRepository.save(invoice); await this.invoiceQueue.add('extract-data', { invoiceId: savedInvoice.id, tenantId, companyId, filePath, }); await this.subscriptionsService.incrementInvoiceCount(tenantId); return savedInvoice; } /** * قائمة الفواتير لشركة محددة */ async findAll(tenantId: string, companyId: string): Promise { return this.invoiceRepository.find({ where: { tenant_id: tenantId, company_id: companyId }, order: { created_at: 'DESC' }, }); } /** * قائمة جميع الفواتير للمكتب */ async findAllByTenant(tenantId: string): Promise { return this.invoiceRepository.find({ where: { tenant_id: tenantId }, relations: ['company'], order: { created_at: 'DESC' }, }); } /** * تفاصيل فاتورة */ async findOne(tenantId: string, id: string): Promise { const invoice = await this.invoiceRepository.findOne({ where: { id, tenant_id: tenantId }, relations: ['lines'], }); if (!invoice) throw new NotFoundException('Invoice not found'); return invoice; } /** * تحديث بيانات الفاتورة يدوياً */ async update(tenantId: string, id: string, updateData: any): Promise { const invoice = await this.findOne(tenantId, id); if (invoice.status === InvoiceStatus.APPROVED || invoice.status === InvoiceStatus.SUBMITTING) { throw new ForbiddenException('Cannot edit already approved or submitted invoice'); } Object.assign(invoice, updateData); return this.invoiceRepository.save(invoice); } /** * إرسال الفاتورة إلى بوابة جو فوترة الحكومية */ async submitToJoFotara(tenantId: string, id: string): Promise { const invoice = await this.findOne(tenantId, id); if (invoice.status !== InvoiceStatus.VALIDATED && invoice.status !== InvoiceStatus.VALIDATION_FAILED && invoice.status !== InvoiceStatus.EXTRACTED) { throw new ForbiddenException('Invoice must be validated or extracted before submission'); } const company = await this.companiesService.findOne(tenantId, invoice.company_id); const credentials = await this.companiesService.getDecryptedCredentials(tenantId, invoice.company_id); const xml = this.ublGenerator.generateXML(invoice, company); await this.invoiceRepository.update(id, { status: InvoiceStatus.SUBMITTING }); try { const response = await this.jofotaraGateway.submitInvoice( xml, credentials.clientId, credentials.secretKey, ); await this.invoiceRepository.update(id, { status: InvoiceStatus.APPROVED }); return response; } catch (error) { await this.invoiceRepository.update(id, { status: InvoiceStatus.VALIDATION_FAILED }); throw error; } } /** * حذف الفاتورة والملف التابع لها */ async remove(tenantId: string, id: string): Promise { const invoice = await this.findOne(tenantId, id); // 1. Delete file if exists if (invoice.original_file_path) { await this.localStorageService.deleteFile(invoice.original_file_path); } // 2. Delete from DB (lines will be deleted via CASCADE) await this.invoiceRepository.delete(id); } /** * الحصول على الملف كـ Stream */ async getFile(tenantId: string, id: string): Promise { const invoice = await this.findOne(tenantId, id); if (!invoice.original_file_path) { throw new NotFoundException('Invoice file not found'); } const storageRoot = this.localStorageService['storageRoot']; // Accessing private for path resolve const fullPath = path.join(storageRoot, invoice.original_file_path); if (!fs.existsSync(fullPath)) { throw new NotFoundException('File does not exist on disk'); } const file = fs.createReadStream(fullPath); return new StreamableFile(file); } }